mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Send messages from carplay
This commit is contained in:
parent
5fef04a589
commit
f12cf9978f
10 changed files with 602 additions and 250 deletions
|
|
@ -74,7 +74,8 @@ enum CarPlayIntentDonation {
|
|||
|
||||
let intent: INSendMessageIntent
|
||||
if toUserNum != 0 {
|
||||
let recipientHandle = INPersonHandle(value: String(toUserNum), type: .unknown)
|
||||
let handleValue = "\(toUserNum)@meshtastic.local"
|
||||
let recipientHandle = INPersonHandle(value: handleValue, type: .emailAddress)
|
||||
let recipient = INPerson(
|
||||
personHandle: recipientHandle,
|
||||
nameComponents: nil,
|
||||
|
|
|
|||
|
|
@ -5,11 +5,8 @@
|
|||
// Copyright(c) Garth Vander Houwen 4/16/26.
|
||||
//
|
||||
// CarPlay Communication app scene delegate.
|
||||
// For communication apps, the system provides the messaging UI.
|
||||
// This delegate manages the CarPlay scene lifecycle and shows
|
||||
// favorite contacts and channels for quick messaging via Siri.
|
||||
// Tapping a favorite pushes a CPContactTemplate detail view
|
||||
// with a native message button that launches Siri compose.
|
||||
// Uses a tab bar with Channels and Direct Messages tabs,
|
||||
// matching the main app's Messages navigation structure.
|
||||
//
|
||||
|
||||
import CarPlay
|
||||
|
|
@ -17,6 +14,9 @@ import Combine
|
|||
import CoreData
|
||||
import Intents
|
||||
import OSLog
|
||||
#if canImport(ActivityKit)
|
||||
import ActivityKit
|
||||
#endif
|
||||
|
||||
class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPInterfaceControllerDelegate {
|
||||
|
||||
|
|
@ -26,6 +26,15 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPI
|
|||
PersistenceController.shared.container.viewContext
|
||||
}
|
||||
|
||||
private func lastHeardText(_ date: Date?) -> String {
|
||||
guard let date else { return "Never heard" }
|
||||
let interval = Date().timeIntervalSince(date)
|
||||
if interval < 60 { return "Just now" }
|
||||
if interval < 3600 { return "\(Int(interval / 60))m ago" }
|
||||
if interval < 86400 { return "\(Int(interval / 3600))h ago" }
|
||||
return "\(Int(interval / 86400))d ago"
|
||||
}
|
||||
|
||||
// MARK: - CPTemplateApplicationSceneDelegate
|
||||
|
||||
func templateApplicationScene(
|
||||
|
|
@ -41,11 +50,21 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPI
|
|||
|
||||
// Observe connection state changes and refresh the template
|
||||
AccessoryManager.shared.$isConnected
|
||||
.removeDuplicates()
|
||||
.dropFirst() // Skip initial value — we already set the root template above
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
.sink { [weak self] isConnected in
|
||||
self?.refreshRootTemplate()
|
||||
if isConnected {
|
||||
self?.startLiveActivityIfNeeded()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Start Live Activity immediately if already connected
|
||||
if AccessoryManager.shared.isConnected {
|
||||
startLiveActivityIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
func templateApplicationScene(
|
||||
|
|
@ -53,6 +72,7 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPI
|
|||
didDisconnectInterfaceController interfaceController: CPInterfaceController
|
||||
) {
|
||||
Logger.services.info("🚗 [CarPlay] Disconnected")
|
||||
endLiveActivity()
|
||||
cancellables.removeAll()
|
||||
self.interfaceController = nil
|
||||
}
|
||||
|
|
@ -75,69 +95,116 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPI
|
|||
private func buildRootTemplate() -> CPTemplate {
|
||||
let connected = AccessoryManager.shared.isConnected
|
||||
|
||||
// Channels tab
|
||||
let channelsTab = buildChannelsTab(connected: connected)
|
||||
|
||||
// Direct Messages tab
|
||||
let directMessagesTab = buildDirectMessagesTab(connected: connected)
|
||||
|
||||
let tabBar = CPTabBarTemplate(templates: [channelsTab, directMessagesTab])
|
||||
return tabBar
|
||||
}
|
||||
|
||||
// MARK: - Channels Tab
|
||||
|
||||
private func buildChannelsTab(connected: Bool) -> CPListTemplate {
|
||||
var sections = [CPListSection]()
|
||||
|
||||
// Status section
|
||||
let statusItem = CPListItem(
|
||||
text: connected ? "Connected" : "Not Connected",
|
||||
detailText: connected
|
||||
? (AccessoryManager.shared.activeConnection?.device.name ?? "Unknown Device")
|
||||
: "Open Meshtastic on your phone to connect",
|
||||
image: UIImage(systemName: connected
|
||||
? "antenna.radiowaves.left.and.right"
|
||||
: "antenna.radiowaves.left.and.right.slash")
|
||||
)
|
||||
statusItem.isEnabled = false
|
||||
sections.append(CPListSection(items: [statusItem], header: "Status", sectionIndexTitle: nil))
|
||||
if connected {
|
||||
let channelItems = fetchChannelItems()
|
||||
if !channelItems.isEmpty {
|
||||
sections.append(CPListSection(items: channelItems))
|
||||
} else {
|
||||
let emptyItem = CPListItem(text: "No Channels", detailText: nil)
|
||||
emptyItem.isEnabled = false
|
||||
sections.append(CPListSection(items: [emptyItem]))
|
||||
}
|
||||
} else {
|
||||
let statusItem = CPListItem(
|
||||
text: "Not Connected",
|
||||
detailText: "Open Meshtastic to connect",
|
||||
image: UIImage(systemName: "antenna.radiowaves.left.and.right.slash")
|
||||
)
|
||||
statusItem.isEnabled = false
|
||||
sections.append(CPListSection(items: [statusItem]))
|
||||
}
|
||||
|
||||
let template = CPListTemplate(title: "Channels", sections: sections)
|
||||
template.tabImage = UIImage(systemName: "bubble.left.and.bubble.right")
|
||||
return template
|
||||
}
|
||||
|
||||
// MARK: - Direct Messages Tab
|
||||
|
||||
private func buildDirectMessagesTab(connected: Bool) -> CPListTemplate {
|
||||
var sections = [CPListSection]()
|
||||
|
||||
if connected {
|
||||
// Favorite contacts section
|
||||
let favoriteItems = fetchFavoriteContactItems()
|
||||
if !favoriteItems.isEmpty {
|
||||
sections.append(CPListSection(items: favoriteItems, header: "Favorites", sectionIndexTitle: nil))
|
||||
}
|
||||
|
||||
// Channels section
|
||||
let channelItems = fetchChannelItems()
|
||||
if !channelItems.isEmpty {
|
||||
sections.append(CPListSection(items: channelItems, header: "Channels", sectionIndexTitle: nil))
|
||||
let dmItems = fetchDirectMessageItems()
|
||||
if !dmItems.isEmpty {
|
||||
sections.append(CPListSection(items: dmItems, header: "Recent", sectionIndexTitle: nil))
|
||||
}
|
||||
|
||||
if favoriteItems.isEmpty && dmItems.isEmpty {
|
||||
let emptyItem = CPListItem(text: "No Messages", detailText: "No direct message history")
|
||||
emptyItem.isEnabled = false
|
||||
sections.append(CPListSection(items: [emptyItem]))
|
||||
}
|
||||
} else {
|
||||
let statusItem = CPListItem(
|
||||
text: "Not Connected",
|
||||
detailText: "Open Meshtastic to connect",
|
||||
image: UIImage(systemName: "antenna.radiowaves.left.and.right.slash")
|
||||
)
|
||||
statusItem.isEnabled = false
|
||||
sections.append(CPListSection(items: [statusItem]))
|
||||
}
|
||||
|
||||
let listTemplate = CPListTemplate(title: "Meshtastic", sections: sections)
|
||||
listTemplate.tabImage = UIImage(systemName: "antenna.radiowaves.left.and.right")
|
||||
return listTemplate
|
||||
let template = CPListTemplate(title: "Direct Messages", sections: sections)
|
||||
template.tabImage = UIImage(systemName: "bubble.left.and.text.bubble.right")
|
||||
return template
|
||||
}
|
||||
|
||||
// MARK: - Data Fetching
|
||||
|
||||
private func fetchFavoriteContactItems() -> [CPListItem] {
|
||||
private func fetchFavoriteContactItems() -> [CPMessageListItem] {
|
||||
let request: NSFetchRequest<NodeInfoEntity> = NodeInfoEntity.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "favorite == YES AND num != %lld", AccessoryManager.shared.activeDeviceNum ?? 0)
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(key: "user.longName", ascending: true)
|
||||
]
|
||||
request.sortDescriptors = [NSSortDescriptor(key: "lastHeard", ascending: false)]
|
||||
request.relationshipKeyPathsForPrefetching = ["user"]
|
||||
|
||||
do {
|
||||
let nodes = try context.fetch(request)
|
||||
return nodes.compactMap { node -> CPListItem? in
|
||||
return nodes.compactMap { node -> CPMessageListItem? in
|
||||
guard let user = node.user else { return nil }
|
||||
let name = user.longName ?? user.shortName ?? "Unknown"
|
||||
let shortName = user.shortName ?? "?"
|
||||
let unreadCount = user.unreadMessages(context: context)
|
||||
let hasUnread = unreadCount > 0
|
||||
|
||||
let detailText = unreadCount > 0 ? "\(shortName) · \(unreadCount) unread" : shortName
|
||||
let item = CPListItem(
|
||||
text: name,
|
||||
detailText: detailText,
|
||||
image: UIImage(systemName: "person.circle.fill")
|
||||
let leadingConfig = CPMessageListItemLeadingConfiguration(
|
||||
leadingItem: .star,
|
||||
leadingImage: UIImage(systemName: "person.circle.fill"),
|
||||
unread: hasUnread
|
||||
)
|
||||
item.handler = { [weak self] _, completion in
|
||||
self?.pushContactTemplate(node: node)
|
||||
completion()
|
||||
}
|
||||
item.isEnabled = true
|
||||
|
||||
let item = CPMessageListItem(
|
||||
fullName: name,
|
||||
phoneOrEmailAddress: "\(node.num)@meshtastic.local",
|
||||
leadingConfiguration: leadingConfig,
|
||||
trailingConfiguration: nil,
|
||||
detailText: hasUnread ? "\(unreadCount) unread" : nil,
|
||||
trailingText: lastHeardText(node.lastHeard)
|
||||
)
|
||||
item.conversationIdentifier = "dm-\(node.num)"
|
||||
item.userInfo = node.num
|
||||
|
||||
donateMessageIntent(toNodeNum: node.num, name: name)
|
||||
|
||||
return item
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -146,7 +213,7 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPI
|
|||
}
|
||||
}
|
||||
|
||||
private func fetchChannelItems() -> [CPListItem] {
|
||||
private func fetchChannelItems() -> [CPMessageListItem] {
|
||||
guard let connectedNum = AccessoryManager.shared.activeDeviceNum,
|
||||
let connectedNode = getNodeInfo(id: connectedNum, context: context),
|
||||
let myInfo = connectedNode.myInfo,
|
||||
|
|
@ -154,71 +221,105 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPI
|
|||
return []
|
||||
}
|
||||
|
||||
return channels.compactMap { channel -> CPListItem? in
|
||||
return channels.compactMap { channel -> CPMessageListItem? in
|
||||
guard channel.role > 0 else { return nil }
|
||||
let name = (channel.name?.isEmpty ?? true)
|
||||
? (channel.index == 0 ? "Primary Channel" : "Channel \(channel.index)")
|
||||
: channel.name!
|
||||
let unreadCount = channel.unreadMessages(context: context)
|
||||
let hasUnread = unreadCount > 0
|
||||
let channelIndex = Int(channel.index)
|
||||
|
||||
let detailText: String
|
||||
if unreadCount > 0 {
|
||||
detailText = (channel.index == 0 ? "Primary" : "Ch \(channel.index)") + " · \(unreadCount) unread"
|
||||
} else {
|
||||
detailText = channel.index == 0 ? "Primary" : "Ch \(channel.index)"
|
||||
}
|
||||
let item = CPListItem(
|
||||
text: name,
|
||||
detailText: detailText,
|
||||
image: UIImage(systemName: channel.index == 0 ? "bubble.left.and.bubble.right.fill" : "bubble.left.and.bubble.right")
|
||||
let leadingConfig = CPMessageListItemLeadingConfiguration(
|
||||
leadingItem: .none,
|
||||
leadingImage: UIImage(systemName: channel.index == 0 ? "bubble.left.and.bubble.right.fill" : "bubble.left.and.bubble.right"),
|
||||
unread: hasUnread
|
||||
)
|
||||
item.handler = { [weak self] _, completion in
|
||||
self?.startChannelMessageIntent(channelIndex: Int(channel.index), channelName: name)
|
||||
completion()
|
||||
}
|
||||
item.isEnabled = true
|
||||
|
||||
let item = CPMessageListItem(
|
||||
conversationIdentifier: "channel-\(channelIndex)",
|
||||
text: name,
|
||||
leadingConfiguration: leadingConfig,
|
||||
trailingConfiguration: nil,
|
||||
detailText: hasUnread ? "\(unreadCount) unread" : (channel.index == 0 ? "Primary" : "Ch \(channel.index)"),
|
||||
trailingText: nil
|
||||
)
|
||||
item.phoneOrEmailAddress = "channel-\(channelIndex)@meshtastic.local"
|
||||
item.userInfo = channelIndex
|
||||
|
||||
donateChannelIntent(channelIndex: channelIndex, channelName: name)
|
||||
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Contact Detail Template
|
||||
private func fetchDirectMessageItems() -> [CPMessageListItem] {
|
||||
let request: NSFetchRequest<UserEntity> = UserEntity.fetchRequest()
|
||||
let connectedNum = AccessoryManager.shared.activeDeviceNum ?? 0
|
||||
|
||||
private func pushContactTemplate(node: NodeInfoEntity) {
|
||||
guard let interfaceController,
|
||||
let user = node.user else { return }
|
||||
// Match the app's UserList: exclude self, exclude ignored, exclude favorites (shown above), show unmessagable only if they have messages
|
||||
let notSelf = NSPredicate(format: "userNode.num != %lld", connectedNum)
|
||||
let notIgnored = NSPredicate(format: "userNode.ignored == NO")
|
||||
let notFavorite = NSPredicate(format: "userNode.favorite == NO")
|
||||
let unmessagableFilter = NSCompoundPredicate(type: .or, subpredicates: [
|
||||
NSPredicate(format: "unmessagable == NO"),
|
||||
NSPredicate(format: "receivedMessages.@count > 0 OR sentMessages.@count > 0")
|
||||
])
|
||||
request.predicate = NSCompoundPredicate(type: .and, subpredicates: [notSelf, notIgnored, notFavorite, unmessagableFilter])
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(key: "userNode.lastHeard", ascending: false),
|
||||
NSSortDescriptor(key: "lastMessage", ascending: false),
|
||||
NSSortDescriptor(key: "longName", ascending: true)
|
||||
]
|
||||
request.fetchLimit = 24 // CarPlay limits list items
|
||||
|
||||
let name = user.longName ?? user.shortName ?? "Unknown"
|
||||
let shortName = user.shortName ?? "?"
|
||||
do {
|
||||
let users = try context.fetch(request)
|
||||
return users.compactMap { user -> CPMessageListItem? in
|
||||
guard let node = user.userNode else { return nil }
|
||||
let name = user.longName ?? user.shortName ?? "Unknown"
|
||||
let unreadCount = user.unreadMessages(context: context)
|
||||
let hasUnread = unreadCount > 0
|
||||
let nodeNum = node.num
|
||||
|
||||
let placeholderImage = UIImage(systemName: "person.circle.fill")!
|
||||
.withTintColor(.systemBlue, renderingMode: .alwaysOriginal)
|
||||
let contact = CPContact(name: name, image: placeholderImage)
|
||||
contact.subtitle = shortName
|
||||
if node.hopsAway >= 0 {
|
||||
contact.informativeText = node.hopsAway == 0 ? "Direct" : "\(node.hopsAway) hop\(node.hopsAway == 1 ? "" : "s") away"
|
||||
let leadingConfig = CPMessageListItemLeadingConfiguration(
|
||||
leadingItem: .none,
|
||||
leadingImage: UIImage(systemName: "person.circle.fill"),
|
||||
unread: hasUnread
|
||||
)
|
||||
|
||||
let item = CPMessageListItem(
|
||||
fullName: name,
|
||||
phoneOrEmailAddress: "\(nodeNum)@meshtastic.local",
|
||||
leadingConfiguration: leadingConfig,
|
||||
trailingConfiguration: nil,
|
||||
detailText: hasUnread ? "\(unreadCount) unread" : nil,
|
||||
trailingText: lastHeardText(node.lastHeard)
|
||||
)
|
||||
item.conversationIdentifier = "dm-\(nodeNum)"
|
||||
item.userInfo = nodeNum
|
||||
|
||||
donateMessageIntent(toNodeNum: nodeNum, name: name)
|
||||
|
||||
return item
|
||||
}
|
||||
} catch {
|
||||
Logger.services.error("🚗 [CarPlay] Failed to fetch DM users: \(error.localizedDescription, privacy: .public)")
|
||||
return []
|
||||
}
|
||||
|
||||
// Native message button that launches Siri compose flow
|
||||
let messageButton = CPContactMessageButton(phoneOrEmail: name)
|
||||
contact.actions = [messageButton]
|
||||
|
||||
// Also donate the intent so Siri has context for this contact
|
||||
donateMessageIntent(toNodeNum: node.num, name: name)
|
||||
|
||||
let contactTemplate = CPContactTemplate(contact: contact)
|
||||
interfaceController.pushTemplate(contactTemplate, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
// MARK: - Intent Donation
|
||||
|
||||
private func donateMessageIntent(toNodeNum: Int64, name: String) {
|
||||
let handleValue = "\(toNodeNum)@meshtastic.local"
|
||||
let person = INPerson(
|
||||
personHandle: INPersonHandle(value: "\(toNodeNum)", type: .unknown),
|
||||
personHandle: INPersonHandle(value: handleValue, type: .emailAddress),
|
||||
nameComponents: nil,
|
||||
displayName: name,
|
||||
image: nil,
|
||||
contactIdentifier: nil,
|
||||
customIdentifier: "meshtastic-node-\(toNodeNum)"
|
||||
contactIdentifier: "\(toNodeNum)",
|
||||
customIdentifier: "\(toNodeNum)"
|
||||
)
|
||||
let intent = INSendMessageIntent(
|
||||
recipients: [person],
|
||||
|
|
@ -239,10 +340,19 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPI
|
|||
}
|
||||
}
|
||||
|
||||
private func startChannelMessageIntent(channelIndex: Int, channelName: String) {
|
||||
private func donateChannelIntent(channelIndex: Int, channelName: String) {
|
||||
let channelHandle = "channel-\(channelIndex)@meshtastic.local"
|
||||
let recipient = INPerson(
|
||||
personHandle: INPersonHandle(value: channelHandle, type: .emailAddress),
|
||||
nameComponents: nil,
|
||||
displayName: channelName,
|
||||
image: nil,
|
||||
contactIdentifier: channelHandle,
|
||||
customIdentifier: channelHandle
|
||||
)
|
||||
let groupName = INSpeakableString(spokenPhrase: channelName)
|
||||
let intent = INSendMessageIntent(
|
||||
recipients: nil,
|
||||
recipients: [recipient],
|
||||
outgoingMessageType: .outgoingMessageText,
|
||||
content: nil,
|
||||
speakableGroupName: groupName,
|
||||
|
|
@ -259,4 +369,69 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPI
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Live Activity
|
||||
|
||||
#if canImport(ActivityKit)
|
||||
private func startLiveActivityIfNeeded() {
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
|
||||
Logger.services.info("🚗 [CarPlay] Live Activities not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
// Don't start another if one is already running
|
||||
guard Activity<MeshActivityAttributes>.activities.isEmpty else {
|
||||
Logger.services.info("🚗 [CarPlay] Live Activity already active")
|
||||
return
|
||||
}
|
||||
|
||||
guard let connectedNum = AccessoryManager.shared.activeDeviceNum else { return }
|
||||
let connectedNode = getNodeInfo(id: connectedNum, context: context)
|
||||
let nodeName = connectedNode?.user?.longName ?? "Meshtastic"
|
||||
let nodeShortName = connectedNode?.user?.shortName ?? "?"
|
||||
|
||||
// Fetch latest local stats telemetry
|
||||
let localStats = connectedNode?.telemetries?.filtered(using: NSPredicate(format: "metricsType == 4"))
|
||||
let mostRecent = localStats?.lastObject as? TelemetryEntity
|
||||
|
||||
let timerSeconds = 900 // 15 minute local stats interval
|
||||
let future = Date(timeIntervalSinceNow: Double(timerSeconds))
|
||||
let initialState = MeshActivityAttributes.ContentState(
|
||||
uptimeSeconds: UInt32(mostRecent?.uptimeSeconds ?? 0),
|
||||
channelUtilization: mostRecent?.channelUtilization ?? 0.0,
|
||||
airtime: mostRecent?.airUtilTx ?? 0.0,
|
||||
sentPackets: UInt32(mostRecent?.numPacketsTx ?? 0),
|
||||
receivedPackets: UInt32(mostRecent?.numPacketsRx ?? 0),
|
||||
badReceivedPackets: UInt32(mostRecent?.numPacketsRxBad ?? 0),
|
||||
dupeReceivedPackets: UInt32(mostRecent?.numRxDupe ?? 0),
|
||||
packetsSentRelay: UInt32(mostRecent?.numTxRelay ?? 0),
|
||||
packetsCanceledRelay: UInt32(mostRecent?.numTxRelayCanceled ?? 0),
|
||||
nodesOnline: UInt32(mostRecent?.numOnlineNodes ?? 0),
|
||||
totalNodes: UInt32(mostRecent?.numTotalNodes ?? 0),
|
||||
timerRange: Date.now...future
|
||||
)
|
||||
|
||||
let attributes = MeshActivityAttributes(nodeNum: Int(connectedNum), name: nodeName, shortName: nodeShortName)
|
||||
let content = ActivityContent(state: initialState, staleDate: Calendar.current.date(byAdding: .minute, value: 15, to: Date())!)
|
||||
|
||||
do {
|
||||
let activity = try Activity<MeshActivityAttributes>.request(attributes: attributes, content: content, pushType: nil)
|
||||
Logger.services.info("🚗 [CarPlay] Started Live Activity: \(activity.id)")
|
||||
} catch {
|
||||
Logger.services.error("🚗 [CarPlay] Failed to start Live Activity: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func endLiveActivity() {
|
||||
Task {
|
||||
for activity in Activity<MeshActivityAttributes>.activities {
|
||||
await activity.end(nil, dismissalPolicy: .immediate)
|
||||
Logger.services.info("🚗 [CarPlay] Ended Live Activity: \(activity.id)")
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
private func startLiveActivityIfNeeded() {}
|
||||
private func endLiveActivity() {}
|
||||
#endif
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,6 +87,12 @@
|
|||
<string>INSearchForMessagesIntent</string>
|
||||
<string>INSetMessageAttributeIntent</string>
|
||||
</array>
|
||||
<key>NSUserActivityTypes</key>
|
||||
<array>
|
||||
<string>INSendMessageIntent</string>
|
||||
<string>INSearchForMessagesIntent</string>
|
||||
<string>INSetMessageAttributeIntent</string>
|
||||
</array>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
|
|
|
|||
|
|
@ -10,10 +10,13 @@ import CoreData
|
|||
import Intents
|
||||
|
||||
enum IntentMessageConverters {
|
||||
static let meshtasticDomain = "@meshtastic.local"
|
||||
|
||||
/// Converts a `UserEntity` to an `INPerson` for use with SiriKit intents.
|
||||
/// Uses the `@meshtastic.local` email format so the handle matches `CPContactMessageButton` identifiers.
|
||||
static func inPerson(from user: UserEntity) -> INPerson {
|
||||
let handle = INPersonHandle(value: String(user.num), type: .unknown)
|
||||
let handleValue = "\(user.num)\(meshtasticDomain)"
|
||||
let handle = INPersonHandle(value: handleValue, type: .emailAddress)
|
||||
return INPerson(
|
||||
personHandle: handle,
|
||||
nameComponents: nil,
|
||||
|
|
@ -29,8 +32,8 @@ enum IntentMessageConverters {
|
|||
let sender: INPerson? = message.fromUser.map { inPerson(from: $0) }
|
||||
let recipients: [INPerson]? = message.toUser.map { [inPerson(from: $0)] }
|
||||
let dateSent = Date(timeIntervalSince1970: TimeInterval(message.messageTimestamp))
|
||||
let groupName: INSpeakableString? = message.channel > 0
|
||||
? INSpeakableString(spokenPhrase: "Channel \(message.channel)")
|
||||
let groupName: INSpeakableString? = message.toUser == nil
|
||||
? INSpeakableString(spokenPhrase: channelDisplayName(for: message.channel, named: nil))
|
||||
: nil
|
||||
|
||||
return INMessage(
|
||||
|
|
@ -56,16 +59,30 @@ enum IntentMessageConverters {
|
|||
|
||||
/// Searches for `UserEntity` objects whose name matches the given search term.
|
||||
static func findUsers(matching searchTerm: String, in context: NSManagedObjectContext) -> [UserEntity] {
|
||||
if let nodeNum = directMessageNodeNum(from: searchTerm) {
|
||||
let fetchRequest: NSFetchRequest<UserEntity> = UserEntity.fetchRequest()
|
||||
fetchRequest.fetchLimit = 1
|
||||
fetchRequest.predicate = NSPredicate(format: "num == %lld", nodeNum)
|
||||
return (try? context.fetch(fetchRequest)) ?? []
|
||||
}
|
||||
|
||||
let fetchRequest: NSFetchRequest<UserEntity> = UserEntity.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(
|
||||
format: "longName CONTAINS[cd] %@ OR shortName CONTAINS[cd] %@",
|
||||
searchTerm, searchTerm
|
||||
format: "longName CONTAINS[cd] %@ OR shortName CONTAINS[cd] %@ OR userId CONTAINS[cd] %@",
|
||||
searchTerm, searchTerm, searchTerm
|
||||
)
|
||||
return (try? context.fetch(fetchRequest)) ?? []
|
||||
}
|
||||
|
||||
/// Looks up a `ChannelEntity` by matching name.
|
||||
static func findChannels(matching name: String, in context: NSManagedObjectContext) -> [ChannelEntity] {
|
||||
if let explicitIndex = channelIndex(fromHandleOrName: name) {
|
||||
let fetchRequest: NSFetchRequest<ChannelEntity> = ChannelEntity.fetchRequest()
|
||||
fetchRequest.fetchLimit = 1
|
||||
fetchRequest.predicate = NSPredicate(format: "index == %d", explicitIndex)
|
||||
return (try? context.fetch(fetchRequest)) ?? []
|
||||
}
|
||||
|
||||
let fetchRequest: NSFetchRequest<ChannelEntity> = ChannelEntity.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(
|
||||
format: "name != nil AND name != '' AND name CONTAINS[cd] %@", name
|
||||
|
|
@ -75,7 +92,57 @@ enum IntentMessageConverters {
|
|||
|
||||
/// Resolves a channel index from a spoken group name, defaulting to the primary channel.
|
||||
static func channelIndex(for name: String, in context: NSManagedObjectContext) -> Int {
|
||||
if let explicitIndex = channelIndex(fromHandleOrName: name) {
|
||||
return explicitIndex
|
||||
}
|
||||
|
||||
let channels = findChannels(matching: name, in: context)
|
||||
return channels.first.map { Int($0.index) } ?? 0
|
||||
}
|
||||
|
||||
static func directMessageNodeNum(from value: String) -> Int64? {
|
||||
if let nodeNum = Int64(value) {
|
||||
return nodeNum
|
||||
}
|
||||
|
||||
if value.hasSuffix(meshtasticDomain) {
|
||||
let rawValue = String(value.dropLast(meshtasticDomain.count))
|
||||
return Int64(rawValue)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func channelIndex(fromHandleOrName value: String) -> Int? {
|
||||
if value.caseInsensitiveCompare("Primary Channel") == .orderedSame {
|
||||
return 0
|
||||
}
|
||||
|
||||
if value.hasPrefix("Channel "), let index = Int(value.dropFirst("Channel ".count)) {
|
||||
return index
|
||||
}
|
||||
|
||||
let channelPrefix = "channel-"
|
||||
if value.hasPrefix(channelPrefix) {
|
||||
let remainder = String(value.dropFirst(channelPrefix.count))
|
||||
let rawIndex = remainder.hasSuffix(meshtasticDomain)
|
||||
? String(remainder.dropLast(meshtasticDomain.count))
|
||||
: remainder
|
||||
return Int(rawIndex)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func channelDisplayName(for index: Int32, named name: String?) -> String {
|
||||
if let name, !name.isEmpty {
|
||||
return name
|
||||
}
|
||||
|
||||
if index == 0 {
|
||||
return "Primary Channel"
|
||||
}
|
||||
|
||||
return "Channel \(index)"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,22 @@ final class SearchForMessagesIntentHandler: NSObject, INSearchForMessagesIntentH
|
|||
predicates.append(NSPredicate(format: "admin == NO"))
|
||||
predicates.append(NSPredicate(format: "isEmoji == NO"))
|
||||
|
||||
// Filter by conversation identifiers (e.g., "dm-123456" or "channel-0")
|
||||
// This is the primary filter when Siri reads messages for a CarPlay contact.
|
||||
if let conversationIds = intent.conversationIdentifiers, !conversationIds.isEmpty {
|
||||
var conversationPredicates: [NSPredicate] = []
|
||||
for convId in conversationIds {
|
||||
if convId.hasPrefix("dm-"), let nodeNum = Int64(convId.dropFirst("dm-".count)) {
|
||||
conversationPredicates.append(NSPredicate(format: "fromUser.num == %lld", nodeNum))
|
||||
} else if convId.hasPrefix("channel-"), let channelIndex = Int32(convId.dropFirst("channel-".count)) {
|
||||
conversationPredicates.append(NSPredicate(format: "channel == %d AND toUser == nil", channelIndex))
|
||||
}
|
||||
}
|
||||
if !conversationPredicates.isEmpty {
|
||||
predicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: conversationPredicates))
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by identifiers (specific message IDs)
|
||||
if let identifiers = intent.identifiers, !identifiers.isEmpty {
|
||||
let messageIds = identifiers.compactMap { Int64($0) }
|
||||
|
|
@ -37,9 +53,12 @@ final class SearchForMessagesIntentHandler: NSObject, INSearchForMessagesIntentH
|
|||
}
|
||||
}
|
||||
|
||||
// Filter by sender
|
||||
// Filter by sender — parse @meshtastic.local email-format handles
|
||||
if let senders = intent.senders, !senders.isEmpty {
|
||||
let senderNums = senders.compactMap { $0.personHandle?.value }.compactMap { Int64($0) }
|
||||
let senderNums = senders.compactMap { sender -> Int64? in
|
||||
guard let handleValue = sender.personHandle?.value else { return nil }
|
||||
return IntentMessageConverters.directMessageNodeNum(from: handleValue)
|
||||
}
|
||||
if !senderNums.isEmpty {
|
||||
predicates.append(NSPredicate(format: "fromUser.num IN %@", senderNums))
|
||||
}
|
||||
|
|
@ -62,16 +81,19 @@ final class SearchForMessagesIntentHandler: NSObject, INSearchForMessagesIntentH
|
|||
}
|
||||
}
|
||||
|
||||
// Filter by group/channel name
|
||||
// Filter by group/channel name or handle
|
||||
if let groupNames = intent.speakableGroupNames, !groupNames.isEmpty {
|
||||
let channelIndices: [Int32] = groupNames.compactMap { groupName in
|
||||
if let idx = IntentMessageConverters.channelIndex(fromHandleOrName: groupName.spokenPhrase) {
|
||||
return Int32(idx)
|
||||
}
|
||||
let channels = IntentMessageConverters.findChannels(
|
||||
matching: groupName.spokenPhrase, in: context
|
||||
)
|
||||
return channels.first.map { Int32($0.index) }
|
||||
}
|
||||
if !channelIndices.isEmpty {
|
||||
predicates.append(NSPredicate(format: "channel IN %@", channelIndices))
|
||||
predicates.append(NSPredicate(format: "channel IN %@ AND toUser == nil", channelIndices))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,21 @@ final class SendMessageIntentHandler: NSObject, INSendMessageIntentHandling {
|
|||
}
|
||||
|
||||
let context = PersistenceController.shared.container.viewContext
|
||||
let searchTerm = recipients[0].displayName
|
||||
let recipient = recipients[0]
|
||||
let handleValue = recipient.personHandle?.value ?? ""
|
||||
|
||||
// If this is a channel handle, accept it directly
|
||||
if IntentMessageConverters.channelIndex(fromHandleOrName: handleValue) != nil {
|
||||
return [.success(with: recipient)]
|
||||
}
|
||||
|
||||
// If the handle resolves to a node number, accept it directly
|
||||
if IntentMessageConverters.directMessageNodeNum(from: handleValue) != nil {
|
||||
return [.success(with: recipient)]
|
||||
}
|
||||
|
||||
// Otherwise search by display name
|
||||
let searchTerm = recipient.displayName ?? handleValue
|
||||
let matchingUsers = await MainActor.run {
|
||||
IntentMessageConverters.findUsers(matching: searchTerm, in: context)
|
||||
}
|
||||
|
|
@ -71,11 +85,15 @@ final class SendMessageIntentHandler: NSObject, INSendMessageIntentHandling {
|
|||
}
|
||||
|
||||
if matchingChannels.count == 1, let channel = matchingChannels.first {
|
||||
let speakable = INSpeakableString(spokenPhrase: channel.name ?? "Channel \(channel.index)")
|
||||
let speakable = INSpeakableString(
|
||||
spokenPhrase: IntentMessageConverters.channelDisplayName(for: channel.index, named: channel.name)
|
||||
)
|
||||
return .success(with: speakable)
|
||||
} else if matchingChannels.count > 1 {
|
||||
let speakables = matchingChannels.map {
|
||||
INSpeakableString(spokenPhrase: $0.name ?? "Channel \($0.index)")
|
||||
INSpeakableString(
|
||||
spokenPhrase: IntentMessageConverters.channelDisplayName(for: $0.index, named: $0.name)
|
||||
)
|
||||
}
|
||||
return .disambiguation(with: speakables)
|
||||
}
|
||||
|
|
@ -120,16 +138,27 @@ final class SendMessageIntentHandler: NSObject, INSendMessageIntentHandling {
|
|||
replyID: 0
|
||||
)
|
||||
} else if let recipient = intent.recipients?.first,
|
||||
let handleValue = recipient.personHandle?.value,
|
||||
let nodeNum = Int64(handleValue) {
|
||||
let handleValue = recipient.personHandle?.value {
|
||||
if let channelIndex = IntentMessageConverters.channelIndex(fromHandleOrName: handleValue) {
|
||||
try await AccessoryManager.shared.sendMessage(
|
||||
message: content,
|
||||
toUserNum: 0,
|
||||
channel: Int32(channelIndex),
|
||||
isEmoji: false,
|
||||
replyID: 0
|
||||
)
|
||||
} else if let nodeNum = IntentMessageConverters.directMessageNodeNum(from: handleValue) {
|
||||
// Direct message to a single node
|
||||
try await AccessoryManager.shared.sendMessage(
|
||||
message: content,
|
||||
toUserNum: nodeNum,
|
||||
channel: 0,
|
||||
isEmoji: false,
|
||||
replyID: 0
|
||||
)
|
||||
try await AccessoryManager.shared.sendMessage(
|
||||
message: content,
|
||||
toUserNum: nodeNum,
|
||||
channel: 0,
|
||||
isEmoji: false,
|
||||
replyID: 0
|
||||
)
|
||||
} else {
|
||||
return INSendMessageIntentResponse(code: .failure, userActivity: nil)
|
||||
}
|
||||
} else {
|
||||
return INSendMessageIntentResponse(code: .failure, userActivity: nil)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -376,7 +376,7 @@ struct Connect: View {
|
|||
let localStats = node?.telemetries?.filtered(using: NSPredicate(format: "metricsType == 4"))
|
||||
let mostRecent = localStats?.lastObject as? TelemetryEntity
|
||||
|
||||
let activityAttributes = MeshActivityAttributes(nodeNum: Int(node?.num ?? 0), name: node?.user?.longName?.addingVariationSelectors ?? "unknown")
|
||||
let activityAttributes = MeshActivityAttributes(nodeNum: Int(node?.num ?? 0), name: node?.user?.longName?.addingVariationSelectors ?? "unknown", shortName: node?.user?.shortName ?? "?")
|
||||
|
||||
let future = Date(timeIntervalSinceNow: Double(timerSeconds))
|
||||
let initialContentState = MeshActivityAttributes.ContentState(uptimeSeconds: UInt32(mostRecent?.uptimeSeconds ?? 0),
|
||||
|
|
|
|||
|
|
@ -130,7 +130,8 @@ extension CarPlayIntentDonation {
|
|||
let me = mePerson()
|
||||
|
||||
if toUserNum != 0 {
|
||||
let recipientHandle = INPersonHandle(value: String(toUserNum), type: .unknown)
|
||||
let handleValue = "\(toUserNum)@meshtastic.local"
|
||||
let recipientHandle = INPersonHandle(value: handleValue, type: .emailAddress)
|
||||
let recipient = INPerson(
|
||||
personHandle: recipientHandle,
|
||||
nameComponents: nil,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ struct MeshActivityAttributes: ActivityAttributes {
|
|||
// Fixed non-changing properties about your activity go here!
|
||||
var nodeNum: Int
|
||||
var name: String
|
||||
var shortName: String
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ struct WidgetsLiveActivity: Widget {
|
|||
|
||||
ActivityConfiguration(for: MeshActivityAttributes.self) { context in
|
||||
LiveActivityView(nodeName: context.attributes.name,
|
||||
uptimeSeconds: 0, // context.attributes.uptimeSeconds,
|
||||
uptimeSeconds: context.state.uptimeSeconds,
|
||||
channelUtilization: context.state.channelUtilization,
|
||||
airtime: context.state.airtime,
|
||||
sentPackets: context.state.sentPackets,
|
||||
|
|
@ -31,18 +31,16 @@ struct WidgetsLiveActivity: Widget {
|
|||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
if context.state.totalNodes > 0 {
|
||||
Text(" \(context.state.nodesOnline) online")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize()
|
||||
} else {
|
||||
Text(" ")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize()
|
||||
}
|
||||
Text("Ch. Util: \(context.state.channelUtilization?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)%")
|
||||
Text(context.attributes.shortName)
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.primary)
|
||||
.fixedSize()
|
||||
Text("Sent: \(context.state.sentPackets)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize()
|
||||
Text("ChUtil: \(context.state.channelUtilization?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)%")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize()
|
||||
|
|
@ -50,10 +48,6 @@ struct WidgetsLiveActivity: Widget {
|
|||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize()
|
||||
Text("Sent: \(context.state.sentPackets)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize()
|
||||
Text("Received: \(context.state.receivedPackets)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
|
|
@ -64,52 +58,95 @@ struct WidgetsLiveActivity: Widget {
|
|||
.tint(Color("LightIndigo"))
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing, priority: 1) {
|
||||
Spacer()
|
||||
if context.state.totalNodes > 0 {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "person.2.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(context.state.nodesOnline)/\(context.state.totalNodes)")
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.fixedSize()
|
||||
}
|
||||
Text("Bad: \(context.state.badReceivedPackets)")
|
||||
.font(.caption)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize()
|
||||
Text("Dupe: \(context.state.dupeReceivedPackets)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize()
|
||||
Text("Relayed: \(context.state.packetsSentRelay)")
|
||||
.font(.caption)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize()
|
||||
Text("Relay Cancel: \(context.state.packetsCanceledRelay)")
|
||||
.font(.caption)
|
||||
Text("Relayed: \(context.state.packetsSentRelay)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize()
|
||||
Text("Rly Cancel: \(context.state.packetsCanceledRelay)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize()
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
Text("Last Heard: \(Date().formatted())")
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.tint)
|
||||
.fixedSize()
|
||||
HStack(spacing: 4) {
|
||||
if let uptime = context.state.uptimeSeconds, uptime > 0 {
|
||||
Text("UPTIME:")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tint)
|
||||
Text(uptime >= 3600 ? "\(uptime / 3600)h \((uptime % 3600) / 60)m" : "\((uptime % 3600) / 60)m")
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.tint)
|
||||
Text("•")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
Text("UPDATED:")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tint)
|
||||
Text("\(Date().formatted(date: .omitted, time: .shortened))")
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
|
||||
} compactLeading: {
|
||||
Image("m-logo-black")
|
||||
.resizable()
|
||||
.frame(width: 25)
|
||||
.padding(4)
|
||||
.background(.green.gradient, in: ContainerRelativeShape())
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "person.2.fill")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.green)
|
||||
if context.state.totalNodes > 0 {
|
||||
Text("\(context.state.nodesOnline)")
|
||||
.font(.caption2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
.fixedSize()
|
||||
} compactTrailing: {
|
||||
Text(timerInterval: context.state.timerRange, countsDown: true)
|
||||
.monospacedDigit()
|
||||
.foregroundColor(Color("LightIndigo"))
|
||||
.frame(width: 40)
|
||||
Text("\(context.state.channelUtilization?.formatted(.number.precision(.fractionLength(1))) ?? "--")%")
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.primary)
|
||||
.fixedSize()
|
||||
} minimal: {
|
||||
Image("m-logo-black")
|
||||
.resizable()
|
||||
.frame(width: 24.0)
|
||||
.padding(4)
|
||||
.background(.green.gradient, in: ContainerRelativeShape())
|
||||
ZStack {
|
||||
Image(systemName: "person.2.fill")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.green)
|
||||
if context.state.totalNodes > 0 {
|
||||
Text("\(context.state.nodesOnline)")
|
||||
.font(.system(size: 7, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.offset(y: 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
.contentMargins(.trailing, 32, for: .expanded)
|
||||
.contentMargins([.leading, .top, .bottom], 6, for: .compactLeading)
|
||||
.contentMargins(.leading, 16, for: .expanded)
|
||||
.contentMargins(.trailing, 16, for: .expanded)
|
||||
.contentMargins(.all, 6, for: .compactLeading)
|
||||
.contentMargins(.all, 6, for: .compactTrailing)
|
||||
.contentMargins(.all, 6, for: .minimal)
|
||||
.widgetURL(URL(string: "meshtastic:///connect"))
|
||||
}
|
||||
|
|
@ -117,7 +154,7 @@ struct WidgetsLiveActivity: Widget {
|
|||
}
|
||||
|
||||
struct WidgetsLiveActivity_Previews: PreviewProvider {
|
||||
static let attributes = MeshActivityAttributes(nodeNum: 123456789, name: "RAK Compact Rotary Handset Gray 8E6G")
|
||||
static let attributes = MeshActivityAttributes(nodeNum: 123456789, name: "RAK Compact Rotary Handset Gray 8E6G", shortName: "8E6G")
|
||||
static let state = MeshActivityAttributes.ContentState(uptimeSeconds: 600, channelUtilization: 1.2, airtime: 3.5, sentPackets: 12587, receivedPackets: 12555, badReceivedPackets: 800, dupeReceivedPackets: 100, packetsSentRelay: 250, packetsCanceledRelay: 372, nodesOnline: 99, totalNodes: 100, timerRange: Date.now...Date(timeIntervalSinceNow: 300))
|
||||
|
||||
static var previews: some View {
|
||||
|
|
@ -154,108 +191,122 @@ struct LiveActivityView: View {
|
|||
var timerRange: ClosedRange<Date>
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Image(colorScheme == .light ? "m-logo-black" : "m-logo-white")
|
||||
.resizable()
|
||||
.clipShape(ContainerRelativeShape())
|
||||
.opacity(isLuminanceReduced ? 0.5 : 1.0)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(minWidth: 25, idealWidth: 45, maxWidth: 55)
|
||||
Spacer()
|
||||
NodeInfoView(isLuminanceReduced: _isLuminanceReduced, nodeName: nodeName, uptimeSeconds: uptimeSeconds, channelUtilization: channelUtilization, airtime: airtime, sentPackets: sentPackets, receivedPackets: receivedPackets, badReceivedPackets: badReceivedPackets,
|
||||
dupeReceivedPackets: dupeReceivedPackets, packetsSentRelay: packetsSentRelay, packetsCanceledRelay: packetsCanceledRelay, nodesOnline: nodesOnline, timerRange: timerRange)
|
||||
Spacer()
|
||||
}
|
||||
.tint(.primary)
|
||||
.padding([.leading, .top, .bottom])
|
||||
.padding(.trailing, 25)
|
||||
.activityBackgroundTint(colorScheme == .light ? Color("LiveActivityBackground") : Color("AccentColorDimmed"))
|
||||
.activitySystemActionForegroundColor(.primary)
|
||||
}
|
||||
}
|
||||
let errorRate = receivedPackets > 0
|
||||
? (Double(badReceivedPackets) / Double(receivedPackets)) * 100
|
||||
: 0.0
|
||||
let now = Date()
|
||||
|
||||
struct NodeInfoView: View {
|
||||
@Environment(\.isLuminanceReduced) var isLuminanceReduced
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
// Header row: logo + node name + nodes online
|
||||
HStack(spacing: 6) {
|
||||
Image("m-logo-white")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 24, height: 24)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
Text(nodeName)
|
||||
.font(.callout)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.tint)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if totalNodes > 0 {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "person.2.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(nodesOnline)/\(totalNodes)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
|
||||
var nodeName: String
|
||||
var uptimeSeconds: UInt32?
|
||||
var channelUtilization: Float?
|
||||
var airtime: Float?
|
||||
var sentPackets: UInt32
|
||||
var receivedPackets: UInt32
|
||||
var badReceivedPackets: UInt32
|
||||
var dupeReceivedPackets: UInt32
|
||||
var packetsSentRelay: UInt32
|
||||
var packetsCanceledRelay: UInt32
|
||||
var nodesOnline: UInt32
|
||||
var timerRange: ClosedRange<Date>
|
||||
// Stats grid — two columns
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
StatRow(label: "Ch. Utilization", value: "\(channelUtilization?.formatted(.number.precision(.fractionLength(1))) ?? "--")%")
|
||||
StatRow(label: "Airtime", value: "\(airtime?.formatted(.number.precision(.fractionLength(1))) ?? "--")%")
|
||||
StatRow(label: "Sent", value: "\(sentPackets)")
|
||||
StatRow(label: "Received", value: "\(receivedPackets)")
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
StatRow(label: "Error Rate", value: "\(errorRate.formatted(.number.precision(.fractionLength(1))))%")
|
||||
StatRow(label: "Relayed", value: "\(packetsSentRelay)")
|
||||
StatRow(label: "Relay Canceled", value: "\(packetsCanceledRelay)")
|
||||
StatRow(label: "Duplicate", value: "\(dupeReceivedPackets)")
|
||||
}
|
||||
}
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.opacity(isLuminanceReduced ? 0.8 : 1.0)
|
||||
|
||||
var body: some View {
|
||||
let errorRate = (Double(badReceivedPackets) / Double(receivedPackets)) * 100
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(nodeName)
|
||||
.font(nodeName.count > 14 ? .callout : .title3)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.tint)
|
||||
// Text("\(channelUtilization.map { String(format: "Ch. Util: %.2f", $0 ) } ?? "--")% \(airtime.map { String(format: "Airtime: %.2f", $0) } ?? "--")%")
|
||||
Text("Ch. Util: \(channelUtilization?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)%")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
.opacity(isLuminanceReduced ? 0.8 : 1.0)
|
||||
.fixedSize()
|
||||
Text("Packets: Sent \(sentPackets) Rec. \(receivedPackets)")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
.opacity(isLuminanceReduced ? 0.8 : 1.0)
|
||||
.fixedSize()
|
||||
Text("Bad: \(badReceivedPackets) Error Rate: \(errorRate.formatted(.number.precision(.fractionLength(2))))%")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
.opacity(isLuminanceReduced ? 0.8 : 1.0)
|
||||
.fixedSize()
|
||||
|
||||
Text("Connected: \(nodesOnline) nodes online")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
.opacity(isLuminanceReduced ? 0.8 : 1.0)
|
||||
.fixedSize()
|
||||
|
||||
let now = Date()
|
||||
Text("Last Heard: \(now.formatted())")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
.opacity(isLuminanceReduced ? 0.8 : 1.0)
|
||||
.fixedSize()
|
||||
// Footer: uptime + timer
|
||||
HStack {
|
||||
|
||||
if timerRange.upperBound >= now {
|
||||
Text("Next Update:")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
Spacer(minLength: 0)
|
||||
if let uptimeSeconds, uptimeSeconds > 0 {
|
||||
Text("Uptime:")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(uptimeText(uptimeSeconds))
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.tint)
|
||||
Text("•")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if timerRange.upperBound >= now {
|
||||
Text("Update in:")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.opacity(isLuminanceReduced ? 0.8 : 1.0)
|
||||
.fixedSize()
|
||||
Text(timerInterval: timerRange, countsDown: true)
|
||||
.monospacedDigit()
|
||||
.multilineTextAlignment(.leading)
|
||||
.font(.caption)
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.tint)
|
||||
} else {
|
||||
Text("Not Connected")
|
||||
.multilineTextAlignment(.leading)
|
||||
.font(.caption)
|
||||
.font(.caption2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.tint(.primary)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.activityBackgroundTint(colorScheme == .light ? Color("LiveActivityBackground") : Color("AccentColorDimmed"))
|
||||
.activitySystemActionForegroundColor(.primary)
|
||||
}
|
||||
|
||||
private func uptimeText(_ seconds: UInt32) -> String {
|
||||
let hours = seconds / 3600
|
||||
let minutes = (seconds % 3600) / 60
|
||||
if hours > 0 {
|
||||
return "\(hours)h \(minutes)m"
|
||||
}
|
||||
return "\(minutes)m"
|
||||
}
|
||||
}
|
||||
|
||||
struct StatRow: View {
|
||||
var label: String
|
||||
var value: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -265,27 +316,26 @@ struct TimerView: View {
|
|||
var timerRange: ClosedRange<Date>
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .center) {
|
||||
VStack(alignment: .center, spacing: 2) {
|
||||
Text("UPDATE IN")
|
||||
.font(.caption2)
|
||||
.allowsTightening(/*@START_MENU_TOKEN@*/true/*@END_MENU_TOKEN@*/)
|
||||
.allowsTightening(true)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
.opacity(isLuminanceReduced ? 0.5 : 1.0)
|
||||
Text(timerInterval: timerRange, countsDown: true)
|
||||
.monospacedDigit()
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(width: 80)
|
||||
.font(.callout)
|
||||
.frame(width: 60)
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.tint)
|
||||
Image(systemName: "timer")
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.resizable()
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 30, height: 30)
|
||||
.frame(width: 20, height: 20)
|
||||
.opacity(isLuminanceReduced ? 0.5 : 1.0)
|
||||
.offset(y: -5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue