|
1 | | -'''Module to get entries from OpenSenseMap API and get the average temperature''' |
| 1 | +"""Module to get entries from OpenSenseMap API and get the average temperature""" |
2 | 2 | from datetime import datetime, timezone, timedelta |
3 | | -import re |
| 3 | +import json |
| 4 | +from typing import Iterable, Dict, Tuple, Optional |
4 | 5 | import requests |
5 | 6 | import redis |
| 7 | +import ijson |
| 8 | +from ijson.common import JSONError as IjsonJSONError |
6 | 9 | from app.config import create_redis_client, CACHE_TTL |
7 | 10 |
|
8 | | -# Use shared Redis client |
9 | 11 | redis_client, REDIS_AVAILABLE = create_redis_client() |
10 | 12 |
|
11 | 13 | _sensor_stats = {"total_sensors": 0, "null_count": 0} |
12 | 14 |
|
13 | 15 | def classify_temperature(average): |
14 | | - '''Classify temperature based on ranges using dictionary approach''' |
15 | | - # Define temperature ranges and their classifications |
| 16 | + """Classify temperature based on ranges using dictionary approach""" |
16 | 17 | temp_classifications = { |
17 | 18 | "cold": (float('-inf'), 10, "Warning: Too cold"), |
18 | | - "good": (10, 36, "Good"), |
19 | | - "hot": (36, float('inf'), "Warning: Too hot") |
| 19 | + "good": (10, 36, "Good"), |
| 20 | + "hot": (36, float('inf'), "Warning: Too hot"), |
20 | 21 | } |
21 | | - |
22 | | - # Find the appropriate classification |
23 | 22 | for _, (min_temp, max_temp, status) in temp_classifications.items(): |
24 | 23 | if min_temp < average <= max_temp: |
25 | 24 | return status |
| 25 | + return "Unknown" |
26 | 26 |
|
27 | | - return "Unknown" # Default case |
| 27 | +def _iter_sensors_from_stream(stream) -> Iterable[dict]: |
| 28 | + """Yield sensors from a streaming JSON array of boxes.""" |
| 29 | + yield from ijson.items(stream, 'item.sensors.item') |
| 30 | + |
| 31 | +def _iter_sensors_from_json(boxes: Iterable[dict]) -> Iterable[dict]: |
| 32 | + """Yield sensors from a loaded list of boxes.""" |
| 33 | + for box in boxes: |
| 34 | + yield from box.get("sensors", []) |
| 35 | + |
| 36 | +def _compute_stats(sensors: Iterable[dict]) -> Tuple[float, int, Dict[str, int]]: |
| 37 | + """Compute temperature sum/count and stats from an iterable of sensors.""" |
| 38 | + temp_sum = 0.0 |
| 39 | + temp_count = 0 |
| 40 | + stats = {"total_sensors": 0, "null_count": 0} |
| 41 | + |
| 42 | + for sensor in sensors: |
| 43 | + stats["total_sensors"] += 1 |
| 44 | + if sensor.get('unit') != "°C": |
| 45 | + continue |
| 46 | + |
| 47 | + last = sensor.get('lastMeasurement') |
| 48 | + if not last or 'value' not in last: |
| 49 | + stats["null_count"] += 1 |
| 50 | + continue |
28 | 51 |
|
29 | | -def get_temperature(): |
30 | | - '''Function to get the average temperature from OpenSenseMap API.''' |
31 | | - if REDIS_AVAILABLE: |
32 | 52 | try: |
33 | | - cached_data = redis_client.get("temperature_data") |
34 | | - if cached_data: |
35 | | - print("Using cached data from Redis.") |
36 | | - # Return cached data with default stats (since we don't have fresh stats) |
37 | | - default_stats = {"total_sensors": 0, "null_count": 0} |
38 | | - return cached_data, default_stats |
39 | | - except redis.RedisError as e: |
40 | | - print(f"Redis error: {e}. Proceeding without cache.") |
41 | | - |
42 | | - print("Fetching new data from OpenSenseMap API...") |
43 | | - |
44 | | - # Ensuring that data is not older than 1 hour. |
45 | | - time_iso = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat().replace("+00:00", "Z") |
| 53 | + temp_sum += float(last['value']) |
| 54 | + temp_count += 1 |
| 55 | + except (TypeError, ValueError): |
| 56 | + stats["null_count"] += 1 |
46 | 57 |
|
47 | | - params = { |
48 | | - "date": time_iso, |
49 | | - "format": "json" |
50 | | - } |
| 58 | + return temp_sum, temp_count, stats |
51 | 59 |
|
52 | | - print('Getting data from OpenSenseMap API...') |
| 60 | +def _empty_stats() -> Dict[str, int]: |
| 61 | + return {"total_sensors": 0, "null_count": 0} |
| 62 | + |
| 63 | +def _get_cached_temperature() -> Optional[str]: |
| 64 | + if not REDIS_AVAILABLE: |
| 65 | + return None |
| 66 | + try: |
| 67 | + cached = redis_client.get("temperature_data") |
| 68 | + if cached: |
| 69 | + return cached.decode("utf-8") if isinstance(cached, (bytes, bytearray)) else cached |
| 70 | + except redis.RedisError: |
| 71 | + return None |
| 72 | + return None |
| 73 | + |
| 74 | +def _set_cached_temperature(value: str) -> None: |
| 75 | + if not REDIS_AVAILABLE: |
| 76 | + return |
53 | 77 | try: |
54 | | - response = requests.get( |
| 78 | + redis_client.setex("temperature_data", CACHE_TTL, value) |
| 79 | + except redis.RedisError: |
| 80 | + pass |
| 81 | + |
| 82 | +def _build_params() -> Dict[str, str]: |
| 83 | + time_iso = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat().replace("+00:00", "Z") |
| 84 | + return {"date": time_iso, "format": "json"} |
| 85 | + |
| 86 | +def _request_boxes(params: Dict[str, str]): |
| 87 | + try: |
| 88 | + resp = requests.get( |
55 | 89 | "https://api.opensensemap.org/boxes", |
56 | 90 | params=params, |
57 | | - timeout=(3, 10) |
| 91 | + stream=True, |
| 92 | + timeout=(60, 90) |
58 | 93 | ) |
59 | | - print('Data retrieved successfully!') |
| 94 | + if hasattr(resp, "raise_for_status"): |
| 95 | + resp.raise_for_status() |
| 96 | + if hasattr(resp, "raw") and hasattr(resp.raw, "decode_content"): |
| 97 | + resp.raw.decode_content = True |
| 98 | + return resp, None |
60 | 99 | except requests.Timeout: |
61 | | - print("API request timed out") |
62 | | - return "Error: API request timed out\n", {"total_sensors": 0, "null_count": 0} |
| 100 | + return None, "Error: API request timed out\n" |
63 | 101 | except requests.RequestException as e: |
64 | | - print(f"API request failed: {e}") |
65 | | - return f"Error: API request failed - {e}\n", {"total_sensors": 0, "null_count": 0} |
| 102 | + return None, f"Error: API request failed - {e}\n" |
66 | 103 |
|
67 | | - _sensor_stats["total_sensors"] = sum( |
68 | | - 1 for line in response.text.splitlines() if re.search(r'^\s*"sensors"\s*:\s*\[', line) |
69 | | - ) |
| 104 | +def _make_sensor_iter(response) -> Tuple[Optional[Iterable[dict]], Optional[str]]: |
| 105 | + # Prefer streaming if raw is available; otherwise load JSON once. |
| 106 | + if hasattr(response, "raw") and getattr(response, "raw", None): |
| 107 | + return _iter_sensors_from_stream(response.raw), None |
| 108 | + try: |
| 109 | + boxes = response.json() |
| 110 | + except (json.JSONDecodeError, ValueError) as e: |
| 111 | + return None, f"Error: parse failed - {e}\n" |
| 112 | + return _iter_sensors_from_json(boxes), None |
70 | 113 |
|
71 | | - res = [d.get('sensors') for d in response.json() if 'sensors' in d] |
| 114 | +def get_temperature(): |
| 115 | + '''Function to get the average temperature from OpenSenseMap API.''' |
| 116 | + cached = _get_cached_temperature() |
| 117 | + if cached: |
| 118 | + return cached, _empty_stats() |
72 | 119 |
|
73 | | - temp_list = [] |
74 | | - _sensor_stats["null_count"] = 0 # Initialize counter for null measurements |
| 120 | + params = _build_params() |
| 121 | + response, err = _request_boxes(params) |
| 122 | + if err: |
| 123 | + return err, _empty_stats() |
75 | 124 |
|
76 | | - for sensor_list in res: |
77 | | - for measure in sensor_list: |
78 | | - if measure.get('unit') == "°C" and 'lastMeasurement' in measure: |
79 | | - last_measurement = measure['lastMeasurement'] |
80 | | - if last_measurement is not None and 'value' in last_measurement: |
81 | | - last_measurement_int = float(last_measurement['value']) |
82 | | - temp_list.append(last_measurement_int) |
83 | | - else: |
84 | | - _sensor_stats["null_count"] += 1 |
| 125 | + sensors_iter, err = _make_sensor_iter(response) |
| 126 | + if err or sensors_iter is None: |
| 127 | + return err or "Error: parser unavailable\n", _empty_stats() |
85 | 128 |
|
86 | | - average = sum(temp_list) / len(temp_list) if temp_list else 0 |
| 129 | + try: |
| 130 | + temp_sum, temp_count, stats = _compute_stats(sensors_iter) |
| 131 | + except IjsonJSONError as e: |
| 132 | + return f"Error: parse failed - {e}\n", _empty_stats() |
87 | 133 |
|
88 | | - # Use the dictionary-based classification |
| 134 | + average = (temp_sum / temp_count) if temp_count else 0.0 |
89 | 135 | status = classify_temperature(average) |
90 | 136 | result = f'Average temperature: {average:.2f} °C ({status})\n' |
91 | 137 |
|
92 | | - if REDIS_AVAILABLE: |
93 | | - try: |
94 | | - redis_client.setex("temperature_data", CACHE_TTL, result) |
95 | | - print("Data cached in Redis.") |
96 | | - except redis.RedisError as e: |
97 | | - print(f"Redis error while caching data: {e}") |
98 | | - |
| 138 | + _set_cached_temperature(result) |
| 139 | + _sensor_stats.update(stats) |
99 | 140 | return result, _sensor_stats |
0 commit comments