Skip to content

Commit aa052d2

Browse files
committed
Use robust serial fallback for GPS AT commands and expose diagnostic sensors (#78)
1 parent 54e234d commit aa052d2

8 files changed

Lines changed: 109 additions & 14 deletions

File tree

custom_components/openwrt/api/base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,9 @@ class QModemInfo:
468468
nr5g_rsrp: int | None = None
469469
nr5g_rsrq: int | None = None
470470
nr5g_sinr: int | None = None
471+
gps_latitude: float | None = None
472+
gps_longitude: float | None = None
473+
gps_last_update: str | None = None
471474

472475

473476
@dataclass

custom_components/openwrt/coordinator.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,7 @@ async def _async_update_data(self) -> OpenWrtData:
455455
except Exception as err:
456456
_LOGGER.debug("nlbwmon top hosts fetch failed: %s", err)
457457
if self.config_entry.options.get(CONF_GPS_MODEM_ENABLED, False):
458+
data.qmodem_info.enabled = True
458459
gps_port = self.config_entry.options.get(
459460
CONF_GPS_MODEM_PORT, DEFAULT_GPS_MODEM_PORT
460461
)
@@ -467,10 +468,28 @@ async def _async_update_data(self) -> OpenWrtData:
467468
from .helpers.gps import async_update_gps_location
468469

469470
try:
470-
await async_update_gps_location(self.hass, self.client, gps_port)
471+
res = await async_update_gps_location(
472+
self.hass, self.client, gps_port
473+
)
474+
if res:
475+
lat, lon, last_update = res
476+
data.qmodem_info.gps_latitude = lat
477+
data.qmodem_info.gps_longitude = lon
478+
data.qmodem_info.gps_last_update = last_update
471479
except Exception as gps_err:
472480
_LOGGER.debug("GPS location update failed: %s", gps_err)
473481

482+
# Preserve previous GPS data if we are not currently polling it
483+
if self.data and self.data.qmodem_info:
484+
if data.qmodem_info.gps_latitude is None:
485+
data.qmodem_info.gps_latitude = self.data.qmodem_info.gps_latitude
486+
if data.qmodem_info.gps_longitude is None:
487+
data.qmodem_info.gps_longitude = self.data.qmodem_info.gps_longitude
488+
if data.qmodem_info.gps_last_update is None:
489+
data.qmodem_info.gps_last_update = (
490+
self.data.qmodem_info.gps_last_update
491+
)
492+
474493
return data
475494

476495
async def _async_fetch_mqtt_presence_data(self, data: OpenWrtData) -> None:

custom_components/openwrt/helpers/gps.py

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,33 @@
66
from typing import Any
77

88
from homeassistant.core import HomeAssistant
9+
from homeassistant.util import dt as dt_util
910

1011
_LOGGER = logging.getLogger(__name__)
1112

1213

14+
async def async_execute_at_command(
15+
client: Any, port: str, at_command: str, timeout: int = 2
16+
) -> str:
17+
"""Send an AT command to the serial port using microcom or a universal fallback."""
18+
# Construct a shell command that tries:
19+
# 1. microcom
20+
# 2. stty + echo + timeout/cat
21+
cmd = (
22+
f"if command -v microcom >/dev/null 2>&1; then "
23+
f'echo -e "{at_command}\\r" | microcom -t {timeout}000 {port} 2>/dev/null; '
24+
f"elif command -v stty >/dev/null 2>&1; then "
25+
f"stty -F {port} 9600 -echo igncr icanon onlcr 2>/dev/null; "
26+
f'(sleep 0.2; echo -e "{at_command}\\r" > {port}) & '
27+
f"timeout {timeout} cat {port} 2>/dev/null; "
28+
f"else "
29+
f'(sleep 0.2; echo -e "{at_command}\\r" > {port}) & '
30+
f"timeout {timeout} cat {port} 2>/dev/null; "
31+
f"fi"
32+
)
33+
return await client.execute_command(cmd)
34+
35+
1336
def parse_nmea_coordinate(coord_str: str) -> float | None:
1437
"""Parse NMEA coordinate string (ddmm.mmmmN/S or dddmm.mmmmE/W) into decimal degrees."""
1538
if not coord_str or len(coord_str) < 3:
@@ -39,7 +62,7 @@ def parse_qgpsloc_response(response: str) -> tuple[float, float] | None:
3962
for line in response.splitlines():
4063
line = line.strip()
4164
if "+QGPSLOC:" in line:
42-
# Strip anything before "+QGPSLOC: " to handle echoes or prefixes
65+
# Strip anything before "+QGPSLOC:" to handle echoes or prefixes
4366
start = line.find("+QGPSLOC:")
4467
content = line[start + len("+QGPSLOC:") :].strip()
4568
parts = content.split(",")
@@ -57,22 +80,19 @@ async def async_update_gps_location(
5780
hass: HomeAssistant,
5881
client: Any,
5982
port: str,
60-
) -> bool:
61-
"""Query GPS coordinates from modem and update HA location."""
83+
) -> tuple[float, float, str] | None:
84+
"""Query GPS coordinates from modem, update HA location and return coordinates."""
6285
try:
6386
# Check if GPS is enabled
64-
check_cmd = f'echo -e "AT+QGPS?\\r" | microcom -t 1000 {port}'
65-
check_res = await client.execute_command(check_cmd)
87+
check_res = await async_execute_at_command(client, port, "AT+QGPS?", timeout=1)
6688

6789
# If QGPS is disabled or query returned error, try enabling it
6890
if "+QGPS: 0" in check_res or "ERROR" in check_res:
6991
_LOGGER.debug("GPS is disabled, enabling via AT+QGPS=1")
70-
enable_cmd = f'echo -e "AT+QGPS=1\\r" | microcom -t 1000 {port}'
71-
await client.execute_command(enable_cmd)
92+
await async_execute_at_command(client, port, "AT+QGPS=1", timeout=1)
7293

7394
# Poll the GPS location
74-
loc_cmd = f'echo -e "AT+QGPSLOC?\\r" | microcom -t 2000 {port}'
75-
loc_res = await client.execute_command(loc_cmd)
95+
loc_res = await async_execute_at_command(client, port, "AT+QGPSLOC?", timeout=2)
7696

7797
coords = parse_qgpsloc_response(loc_res)
7898
if coords:
@@ -86,10 +106,11 @@ async def async_update_gps_location(
86106
"longitude": lon,
87107
},
88108
)
89-
return True
109+
last_update = dt_util.now().isoformat()
110+
return lat, lon, last_update
90111
_LOGGER.debug(
91112
"Failed to get valid GPS fix or coordinates from %s: %s", port, loc_res
92113
)
93114
except Exception as err:
94115
_LOGGER.warning("Failed to update GPS location from Quectel modem: %s", err)
95-
return False
116+
return None

custom_components/openwrt/sensor.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -840,6 +840,27 @@ def _get_qmodem_sensors() -> tuple[OpenWrtSensorDescription, ...]:
840840
entity_category=EntityCategory.DIAGNOSTIC,
841841
value_fn=lambda data: data.qmodem_info.nr5g_sinr,
842842
),
843+
OpenWrtSensorDescription(
844+
key="qmodem_gps_latitude",
845+
name="Modem GPS Latitude",
846+
translation_key="qmodem_gps_latitude",
847+
entity_category=EntityCategory.DIAGNOSTIC,
848+
value_fn=lambda data: data.qmodem_info.gps_latitude,
849+
),
850+
OpenWrtSensorDescription(
851+
key="qmodem_gps_longitude",
852+
name="Modem GPS Longitude",
853+
translation_key="qmodem_gps_longitude",
854+
entity_category=EntityCategory.DIAGNOSTIC,
855+
value_fn=lambda data: data.qmodem_info.gps_longitude,
856+
),
857+
OpenWrtSensorDescription(
858+
key="qmodem_gps_last_update",
859+
name="Modem GPS Last Update",
860+
translation_key="qmodem_gps_last_update",
861+
entity_category=EntityCategory.DIAGNOSTIC,
862+
value_fn=lambda data: data.qmodem_info.gps_last_update,
863+
),
843864
)
844865

845866

custom_components/openwrt/strings.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,15 @@
593593
"qmodem_nr5g_sinr": {
594594
"name": "5G NR SINR"
595595
},
596+
"qmodem_gps_latitude": {
597+
"name": "Modem GPS Latitude"
598+
},
599+
"qmodem_gps_longitude": {
600+
"name": "Modem GPS Longitude"
601+
},
602+
"qmodem_gps_last_update": {
603+
"name": "Modem GPS Last Update"
604+
},
596605
"dhcp_lease_count": {
597606
"name": "DHCP Leases"
598607
},

custom_components/openwrt/translations/de.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,15 @@
593593
"qmodem_nr5g_sinr": {
594594
"name": "5G NR SINR"
595595
},
596+
"qmodem_gps_latitude": {
597+
"name": "Modem GPS Breitengrad"
598+
},
599+
"qmodem_gps_longitude": {
600+
"name": "Modem GPS Längengrad"
601+
},
602+
"qmodem_gps_last_update": {
603+
"name": "Modem GPS Letzte Aktualisierung"
604+
},
596605
"dhcp_lease_count": {
597606
"name": "DHCP Leases"
598607
},

custom_components/openwrt/translations/en.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,15 @@
593593
"qmodem_nr5g_sinr": {
594594
"name": "5G NR SINR"
595595
},
596+
"qmodem_gps_latitude": {
597+
"name": "Modem GPS Latitude"
598+
},
599+
"qmodem_gps_longitude": {
600+
"name": "Modem GPS Longitude"
601+
},
602+
"qmodem_gps_last_update": {
603+
"name": "Modem GPS Last Update"
604+
},
596605
"dhcp_lease_count": {
597606
"name": "DHCP Leases"
598607
},

tests/test_gps_location.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,9 @@ async def test_async_update_gps_location(hass):
7272
hass.services, "async_call", new_callable=AsyncMock
7373
) as mock_service_call:
7474
res = await async_update_gps_location(hass, mock_client, "/dev/ttyUSB3")
75-
assert res is True
75+
assert res is not None
76+
assert res[0] == pytest.approx(52.01681667)
77+
assert res[1] == pytest.approx(-0.71821833)
7678
mock_service_call.assert_called_once_with(
7779
"homeassistant",
7880
"set_location",
@@ -94,6 +96,8 @@ async def test_async_update_gps_location(hass):
9496
hass.services, "async_call", new_callable=AsyncMock
9597
) as mock_service_call:
9698
res = await async_update_gps_location(hass, mock_client, "/dev/ttyUSB3")
97-
assert res is True
99+
assert res is not None
100+
assert res[0] == pytest.approx(52.01681667)
101+
assert res[1] == pytest.approx(-0.71821833)
98102
assert mock_client.execute_command.call_count == 3
99103
mock_service_call.assert_called_once()

0 commit comments

Comments
 (0)