Send messages from carplay

This commit is contained in:
Garth Vander Houwen 2026-04-17 19:44:00 -07:00
parent 5fef04a589
commit f12cf9978f
10 changed files with 602 additions and 250 deletions

View file

@ -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,

View file

@ -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
}

View file

@ -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>

View file

@ -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)"
}
}

View file

@ -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))
}
}

View file

@ -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)
}

View file

@ -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),

View file

@ -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,

View file

@ -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

View file

@ -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)
}
}
}