Skip to main content

Measurement & Locale

How the app converts metric device data to locale-aware display values. All rules are defined in Meshtastic Design Standards v1.4, Section 10.

Core Principle

Meshtastic devices transmit all data in metric SI units. The app wraps raw values in Swift Measurement types with the correct source unit, then lets the OS format them for the user's locale. No manual if metric … else imperial branching is needed for most quantities.

Device (protobuf, always metric)
→ Wrap in Measurement<Unit>(value:, unit: .sourceUnit)
→ Format with .formatted(.measurement(...)) or MeasurementFormatter
→ Display (auto-converted to user's locale)

Protobuf Source Units

These are the canonical units the device sends. Always use these as the source unit when constructing Measurement values:

QuantityDevice UnitSwift Unit Type
AltitudemetersUnitLength.meters
Distance (sensor)millimetersUnitLength.millimeters
Ground Speedkm/hUnitSpeed.kilometersPerHour
Wind Speed / Gustm/sUnitSpeed.metersPerSecond
Temperature°CUnitTemperature.celsius
Barometric PressurehPaUnitPressure.hectopascals
Rainfall (1h / 24h)mmUnitLength.millimeters
WeightkgUnitMass.kilograms

Warning — CLLocation.speed returns m/s, not km/h. When wrapping GPS speed, use UnitSpeed.metersPerSecond. Getting the source unit wrong produces silently incorrect conversions.

Formatting APIs

.formatted(.measurement(...))

Preferred for inline text. Auto-converts to the user's locale:

let speed = Measurement(value: newLocation.speed, unit: UnitSpeed.metersPerSecond)
Text(speed.formatted(.measurement(width: .abbreviated,
numberFormatStyle: .number.precision(.fractionLength(0)))))
// → "12 km/h" or "7 mph"

MeasurementFormatter

Used when you need more control (e.g., natural scaling for distances):

let formatter = MeasurementFormatter()
formatter.unitOptions = .naturalScale // 500m stays "500 m", 2500m → "2.5 km"
formatter.numberFormatter.maximumFractionDigits = 1
let distance = Measurement(value: meters, unit: UnitLength.meters)
return formatter.string(from: distance)

MKDistanceFormatter

Used for map-related distances. Automatically picks m/km or ft/mi:

let distanceFormatter = MKDistanceFormatter()
Text(distanceFormatter.string(fromDistance: Double(meters)))

Temperature

Use the formattedTemperature() extension on Float (defined in Meshtastic/Extensions/Float.swift):

// Auto-converts °C → °F based on locale
Text(temperature.formattedTemperature())

When you need the raw converted value (e.g., for chart data points), use localeTemperature():

let displayValue = temperature.localeTemperature() // Double in user's preferred unit

Both methods use kCFLocaleTemperatureUnitKey to detect the user's temperature preference.

Locale Detection

Temperature Unit

let locale = NSLocale.current as NSLocale
let localeUnit = locale.object(forKey: NSLocale.Key(rawValue: "kCFLocaleTemperatureUnitKey"))
if (localeUnit as? String) == "Fahrenheit" {
// Use .fahrenheit
}

Warning — Never force-unwrap locale queries. localeUnit can be nil on some OS versions. Always use as? String with a safe default (Celsius).

Measurement System

let usesMetric = Locale.current.measurementSystem == .metric

Used for quantities where Measurement formatting doesn't fully apply (e.g., choosing decimal precision for rainfall: 0 decimals for mm, 1 for inches).

Units That Never Convert

These are displayed as-is regardless of locale:

QuantityUnitWhy
Barometric PressurehPaInternational meteorological standard
Heading / Bearing° (degrees)Universal navigation convention
RadiationµR/hrStandard dosimetry unit
Coordinatesdecimal degreesUniversal geographic standard
Percentages (humidity, battery)%Universal

Charts & Graphs

Chart axes, tooltips, and annotations must also display locale-aware units:

// Altitude chart Y-axis (PositionAltitudeChart.swift)
AxisValueLabel("""
\(value.as(PlottableMeasurement.self)!
.measurement
.converted(to: Locale.current.measurementSystem == .metric
? .meters : .feet),
format: .measurement(width: .wide,
numberFormatStyle: .number.precision(.fractionLength(0))))
""")

Date & Time

Use CaseAPIExample
Recency indicatorsRelativeDateTimeFormatter()"5 min ago"
TimestampsDate.formatted(date: .numeric, time: .shortened)"5/9/26, 2:30 PM"
Locale-aware templatesDateFormatter.dateFormat(fromTemplate:options:locale:)Respects 12/24hr, date order
Export (machine-readable)DateFormatter with en_US_POSIX locale"2026-05-09_143000"
TAK/CoT XMLDate.ISO8601FormatStyleISO 8601 with fractional seconds

Never hardcode 12-hour or 24-hour format — let the OS handle it via locale-aware formatters.

File Map

FileWhat It Does
Extensions/Float.swiftformattedTemperature(), localeTemperature()
Views/Settings/GPSStatus.swiftGPS speed formatting (m/s source)
Views/Helpers/Weather/LocalWeatherConditions.swiftWeatherKit temperature & wind
Views/Helpers/Weather/NodeWeatherForecast.swiftHourly forecast temperature conversion
Views/Nodes/Helpers/Map/PositionAltitudeChart.swiftLocale-aware altitude chart axis
Views/Nodes/Helpers/NodeDetail.swiftWeight, rainfall, wind, soil temp display
Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swiftTelemetry table columns
Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultSeries.swiftChart gradient temperature thresholds
Views/Helpers/DistanceText.swiftMKDistanceFormatter wrapper
Views/Helpers/CompassView.swiftMeasurementFormatter with .naturalScale
Measurement/CustomFormatters.swiftShared altitudeFormatter

Checklist for New Telemetry Fields

When adding a new sensor value or telemetry display:

  1. Identify the protobuf source unit from the device schema
  2. Wrap in Measurement<Unit>(value:, unit:) with the correct source unit
  3. Format with .formatted(.measurement(...)) — do not hardcode unit strings
  4. If it's a chart, ensure axis labels use the same locale-aware conversion
  5. If it's a universal unit (hPa, degrees, %), display as-is
  6. Test with both US and Metric measurement system settings in the Simulator