iOS/Sources/Shared/API/Webhook/Sensors/ConnectivitySensor.swift
Copilot 9e8df8ad9d
Format BSSID MAC addresses with leading zeros for consistency with Android (#4095)
## Summary
BSSID sensor reported MAC addresses without leading zeros (e.g.,
`18:e8:29:a7:e9:b`), inconsistent with Android companion app and
standard MAC address notation. This breaks cross-platform room presence
tracking.

**Changes:**
- Added `String.formattedBSSID` extension that pads hex octets to 2
characters
- Applied formatting to BSSID sensor state in `ConnectivitySensor`
- Added test coverage for formatting edge cases

```swift
// Before: "18:e8:29:a7:e9:b"
// After:  "18:e8:29:a7:e9:0b"
sensor.State = bssid.formattedBSSID
```

## Screenshots
N/A - Sensor value formatting change only

## Link to pull request in Documentation repository
Documentation: home-assistant/companion.home-assistant#

## Any other notes
**Breaking change:** Users with BSSID-based automations must update to
the new zero-padded format. This aligns iOS with Android, standard MAC
notation, and network management tools like UniFi.

<!-- START COPILOT ORIGINAL PROMPT -->



<details>

<summary>Original prompt</summary>

> 
> ----
> 
> *This section details on the original issue you should resolve*
> 
> <issue_title>BSSID mac address format isn't consistant with Android
Companion app</issue_title>
> <issue_description>**iOS device model, version and app version**
> <!-- Please include your device 'Model Name' and 'Software Version' as
listed in iOS Settings>General>About. Please also give the app version
listed beneath "Home Assistant Companion" in the App Configuration>About
menu within the app, please include the number in brackets -->
> 
> Model Name: IPhone 6S
> Software Version: IOS 14.4.1
> App version: 2021.77
> 
> **Home Assistant Core Version**
> version | core-2021.3.4
> -- | --
> installation_type | Home Assistant Container
> dev | false
> hassio | false
> docker | true
> virtualenv | false
> python_version | 3.8.7
> os_name | Linux
> os_version | 5.3.18-3-pve
> arch | x86_64
> timezone | Europe/Paris
> 
> <details><summary>Home Assistant Community Store</summary>
> 
> GitHub API | ok
> -- | --
> Github API Calls Remaining | 4998
> Installed Version | 1.11.0
> Stage | running
> Available Repositories | 850
> Installed Repositories | 8
> 
> </details>
> 
> <details><summary>Home Assistant Cloud</summary>
> 
> logged_in | false
> -- | --
> can_reach_cert_server | ok
> can_reach_cloud_auth | ok
> can_reach_cloud | ok
> 
> </details>
> 
> <details><summary>Lovelace</summary>
> 
> dashboards | 9
> -- | --
> resources | 6
> views | 31
> mode | storage
> 
> </details>
> 
> **Describe the bug**
> The BSSID sensor reports access point mac address with leading zeros
omitted
> example:
> 
> ```
> 18:e8:29:a7:e9:b
> ```
> The last number is 0x0b
> 
> An adroid device connected the the same access point returns 
> 
> ```
> 18:e8:29:a7:e9:0b
> ```
> **To Reproduce**
> You need to have an access point with mac bytes lower then 0x10
> 
> **Expected behavior**
> Having same BSSID representation than reported by Android App
companion
> 
> **Screenshots**
> Iphone:
>
![image](https://user-images.githubusercontent.com/5980377/113318644-f3901d00-9310-11eb-94a8-63e53675946d.png)
> 
> Android phone
>
![image](https://user-images.githubusercontent.com/5980377/113318689-00ad0c00-9311-11eb-8594-8daa837dbb2a.png)
> 
> 
> **Additional context**
> I'm using BSSID to track room presence based on connected AP. I'm
trying to share BSSID --> access point for all household mobile.
> 
> I'd prefer the version with leading 0 witch is aligned with Unify
management tool.
> 
> </issue_description>
> 
> ## Comments on the Issue (you are @copilot in this section)
> 
> <comments>
> <comment_new><author>@zacwest</author><body>
> We're just slurping the value we read from the system, so we'd need to
sanitize the input to change it. It would also be a breaking change for
modifying what values are sent up, which is less-than-ideal since users
get auto-updated in the apps. I'm not sure there's a great path forward
for resolving this.</body></comment_new>
> </comments>
> 


</details>



<!-- START COPILOT CODING AGENT SUFFIX -->

- Fixes home-assistant/iOS#1563

<!-- START COPILOT CODING AGENT TIPS -->
---

💡 You can make Copilot smarter by setting up custom instructions,
customizing its development environment and configuring Model Context
Protocol (MCP) servers. Learn more [Copilot coding agent
tips](https://gh.io/copilot-coding-agent-tips) in the docs.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: bgoncal <5808343+bgoncal@users.noreply.github.com>
2025-12-16 12:38:06 +00:00

265 lines
8.7 KiB
Swift

import Foundation
import PromiseKit
#if os(iOS)
import CoreTelephony
import Reachability
#endif
final class ConnectivitySensorUpdateSignaler: SensorProviderUpdateSignaler, SensorObserver {
private var isObserving = false
let signal: () -> Void
#if DEBUG
/// Used for unit test to identify when observation is ready
var notifyObservation: (() -> Void)?
#endif
init(signal: @escaping () -> Void) {
self.signal = signal
Current.sensors.register(observer: self)
}
@objc private func connectivityDidChange(_ note: Notification) {
signal()
}
private func observe() {
guard !isObserving else { return }
NotificationCenter.default.addObserver(
self,
selector: #selector(connectivityDidChange(_:)),
name: Current.connectivity.connectivityDidChangeNotification(),
object: nil
)
isObserving = true
#if DEBUG
notifyObservation?()
#endif
}
private func stopObserving() {
guard isObserving else { return }
NotificationCenter.default.removeObserver(
self,
name: Current.connectivity.connectivityDidChangeNotification(),
object: nil
)
isObserving = false
}
func sensorContainer(_ container: SensorContainer, didUpdate update: SensorObserverUpdate) {
update.sensors.done { [weak self] sensors in
let activeRelatedSensors = sensors.filter({ sensor in
sensor.UniqueID == WebhookSensorId.connectivitySSID.rawValue ||
sensor.UniqueID == WebhookSensorId.connectivityBSID.rawValue ||
sensor.UniqueID == WebhookSensorId.connectivityConnectionType.rawValue
})
let activeSensors = activeRelatedSensors.filter({ sensor in
Current.sensors.isEnabled(sensor: sensor)
})
if activeSensors.isEmpty {
self?.stopObserving()
} else {
self?.observe()
}
}
}
func sensorContainer(
_ container: SensorContainer,
didSignalForUpdateBecause reason: SensorContainerUpdateReason,
lastUpdate: SensorObserverUpdate?
) {
/* no-op */
}
}
public class ConnectivitySensor: SensorProvider {
public enum ConnectivityError: Error {
case unsupportedPlatform
case noCarriers
}
public let request: SensorProviderRequest
public required init(request: SensorProviderRequest) {
self.request = request
}
public func sensors() -> Promise<[WebhookSensor]> {
#if os(iOS)
let sensors: Promise<[WebhookSensor]> = firstly { () -> Guarantee<[Result<[WebhookSensor]>]> in
var sensors = [Promise<[WebhookSensor]>]()
sensors.append(ssid())
sensors.append(connectionType())
#if !targetEnvironment(macCatalyst)
sensors.append(cellularProviders())
#endif
return when(resolved: sensors)
}.map { sensors -> [WebhookSensor] in
sensors.compactMap { (result: Result<[WebhookSensor]>) -> [WebhookSensor]? in
if case let .fulfilled(value) = result {
return value
} else {
return nil
}
}.flatMap { $0 }
}
// Set up our observer
let _: ConnectivitySensorUpdateSignaler = request.dependencies.updateSignaler(for: self)
return sensors
#else
return .init(error: ConnectivityError.unsupportedPlatform)
#endif
}
#if os(iOS)
private func ssid() -> Promise<[WebhookSensor]> {
guard Current.connectivity.hasWiFi() else {
return .init(error: ConnectivityError.unsupportedPlatform)
}
return .value([
with(WebhookSensor(name: "SSID", uniqueID: WebhookSensorId.connectivitySSID.rawValue)) { sensor in
if let ssid = Current.connectivity.currentWiFiSSID() {
sensor.State = ssid
sensor.Icon = "mdi:wifi"
} else {
sensor.State = "Not Connected"
sensor.Icon = "mdi:wifi-off"
}
},
with(WebhookSensor(name: "BSSID", uniqueID: WebhookSensorId.connectivityBSID.rawValue)) { sensor in
if let bssid = Current.connectivity.currentWiFiBSSID() {
sensor.State = bssid.formattedBSSID
sensor.Icon = "mdi:wifi-star"
} else {
sensor.State = "Not Connected"
sensor.Icon = "mdi:wifi-off"
}
},
])
}
#endif
#if os(iOS)
private func connectionType() -> Promise<[WebhookSensor]> {
let simple = Current.connectivity.simpleNetworkType()
return .value([
with(WebhookSensor(
name: "Connection Type",
uniqueID: WebhookSensorId.connectivityConnectionType.rawValue
)) { sensor in
sensor.State = simple.description
sensor.Icon = simple.icon
var attributes = Current.connectivity.networkAttributes()
if case .cellular = simple {
let cellular = Current.connectivity.cellularNetworkType()
attributes["Cellular Technology"] = cellular.description
}
sensor.Attributes = attributes
},
])
}
#if !targetEnvironment(macCatalyst)
private func cellularProviders() -> Promise<[WebhookSensor]> {
let networkInfo = Current.connectivity.telephonyCarriers()
let radioTech = Current.connectivity.telephonyRadioAccessTechnology()
if let networkInfo {
return when(fulfilled: networkInfo.map {
carrierSensor(
carrier: $0.value,
radioTech: radioTech?[$0.key],
key: $0.key
)
})
} else {
return .init(error: ConnectivityError.noCarriers)
}
}
private func carrierSensor(
carrier: CTCarrier,
radioTech: String?,
key: String
) -> Guarantee<WebhookSensor> {
let sensor: WebhookSensor
let id = key.last ?? "?"
sensor = WebhookSensor(
name: "SIM \(id)",
uniqueID: "connectivity_sim_\(id)",
icon: "mdi:sim",
state: "Unknown"
)
sensor.State = carrier.carrierName ?? "N/A"
sensor.Attributes = [
"Carrier ID": key,
"Carrier Name": carrier.carrierName ?? "N/A",
"Mobile Country Code": carrier.mobileCountryCode ?? "N/A",
"Mobile Network Code": carrier.mobileNetworkCode ?? "N/A",
"ISO Country Code": carrier.isoCountryCode ?? "N/A",
"Allows VoIP": carrier.allowsVOIP,
]
if let radioTech {
sensor.Attributes?["Current Radio Technology"] = Self.getRadioTechName(radioTech)
}
return .value(sensor)
}
private static func getRadioTechName(_ radioTech: String) -> String? {
switch radioTech {
case CTRadioAccessTechnologyGPRS:
return "General Packet Radio Service (GPRS)"
case CTRadioAccessTechnologyEdge:
return "Enhanced Data rates for GSM Evolution (EDGE)"
case CTRadioAccessTechnologyCDMA1x:
return "Code Division Multiple Access (CDMA 1X)"
case CTRadioAccessTechnologyWCDMA:
return "Wideband Code Division Multiple Access (WCDMA)"
case CTRadioAccessTechnologyHSDPA:
return "High Speed Downlink Packet Access (HSDPA)"
case CTRadioAccessTechnologyHSUPA:
return "High Speed Uplink Packet Access (HSUPA)"
case CTRadioAccessTechnologyCDMAEVDORev0:
return "Code Division Multiple Access Evolution-Data Optimized Revision 0 (CDMA EV-DO Rev. 0)"
case CTRadioAccessTechnologyCDMAEVDORevA:
return "Code Division Multiple Access Evolution-Data Optimized Revision A (CDMA EV-DO Rev. A)"
case CTRadioAccessTechnologyCDMAEVDORevB:
return "Code Division Multiple Access Evolution-Data Optimized Revision B (CDMA EV-DO Rev. B)"
case CTRadioAccessTechnologyeHRPD:
return "High Rate Packet Data (HRPD)"
case CTRadioAccessTechnologyLTE:
return "Long-Term Evolution (LTE)"
default:
switch radioTech {
case CTRadioAccessTechnologyNR:
return "5G"
case CTRadioAccessTechnologyNRNSA:
return "5G Non-Standalone"
default: return nil
}
}
}
#endif
#endif
}