Deye Cloud API to a Daily Solar Report With Bedrock
Pulling one station's history straight from Deye Cloud, then letting Claude write it up
I have a 15.12 kWp array on the roof and a dashboard that shows me every number about it, live. What I wanted was different: one message, once a day, that already answered the questions I actually ask. How much did it produce. Did anything look wrong. What did the weather have to do with it. Roughly what is this costing me this cycle.
Nobody was going to write that message for me, so I built the thing that does.
solar-daily-brief is a small agent that pulls a day of data straight from the Deye Cloud API, turns it into real metrics, and hands them to Claude on Bedrock to write the report. This is how the data side of it works.
Why go straight to Deye, skipping Home Assistant
I already run Home Assistant with a Deye Solar integration, and it shows every entity live. That is the point of it, and also the limit of it: HA's job is dashboards, not scheduled reports, and if the HA coordinator hangs or the box reboots at the wrong moment, whatever depends on it goes down too.
The daily brief cannot depend on that. It needs to run once a day, reliably, and produce a report even on the one day HA is having a bad time. So the collector talks to Deye's own cloud API directly, the same API HA's integration talks to underneath. HA keeps showing live entities locally; the brief has its own path that does not care whether HA is up.
One dependency removed is one less way for a 1am job to silently fail.
Authenticating: a token, a retry, and a code you have to know about
The Deye Cloud OpenAPI v1.0 issues a JWT from one endpoint:
async def authenticate(self) -> None:
url = f"{API_BASE_URL}/account/token"
params = {"appId": self._app_id}
body = {
"appSecret": self._app_secret,
"email": self._email,
"password": self._password_hash,
}
data = await self._post(url, body, params=params, authenticated=False)
self._token = data["accessToken"]
expires_in = int(data.get("expiresIn", 5184000))
self._token_expires_at = time.time() + expires_in
The password is not the plain password. Deye wants the SHA256 hex digest of it, sent as password. If you send the plain string, authentication fails with no hint that hashing was the problem.
expiresIn comes back around 5,184,000 seconds, roughly 60 days, so a well-behaved client caches the token and only re-authenticates near the end of that window. In practice you cannot fully trust "well-behaved" from any third-party API, so the client also treats a 401 and a specific error code as reasons to refresh and retry once:
if resp.status_code == 401:
if retry_on_401 and authenticated:
await self.authenticate()
return await self._post(url, body, params=params, retry_on_401=False)
raise DeyeAuthError("Authentication failed (401)")
payload = resp.json()
if not payload.get("success", False):
code = str(payload.get("code", ""))
if code == "2101019" and retry_on_401 and authenticated:
await self.authenticate()
return await self._post(url, body, params=params, retry_on_401=False)
2101019 is Deye's own "invalid token" code, returned with a 200 and success: false, not with a 401. If you only check the HTTP status, you miss it and the whole call fails for no visible reason. This is the kind of quirk that only shows up after the token has actually expired once in production, which is exactly when you do not want to be debugging it.
One endpoint, two granularities
Almost everything comes from a single call: POST /station/history.
async def _history(self, *, granularity: int, start_at: str, end_at: str):
body = {
"stationId": self.station_id,
"granularity": granularity,
"startAt": start_at,
"endAt": end_at,
}
data = await self._post(f"{API_BASE_URL}/station/history", body)
return data.get("stationDataItems", []) or []
granularity=2 returns one row per day: generationValue, purchaseValue (grid import), gridValue (grid export), chargeValue, dischargeValue. Day totals, already summed by Deye.
granularity=1 returns a row roughly every 5 minutes: instantaneous generationPower, consumptionPower, and batterySOC. This is where peaks and time-of-day come from, and it is also where the API's edges show up. The range end is inclusive, not exclusive as an earlier version of the docs implied. I only found that out by asking for three days and counting four, so the collector filters the returned rows against the requested range instead of trusting the API to do it.
From raw frames to numbers that mean something
The day-summary endpoint hands you consumptionValue as one of its fields, and it is tempting to just use it. Don't. It is not measured, it is Deye's own residual: generation minus grid minus battery delta. Every error in the grid CT reading leaks straight into that number.
A cleaner number comes from integrating consumptionPower across the day's frames yourself:
def _integrate_power_kwh(frames: list[dict], field: str) -> float:
points = sorted(
(f["timeStamp"], f[field])
for f in frames
if f.get(field) is not None and f.get("timeStamp") is not None
)
kwh = 0.0
for (t0, w0), (t1, w1) in zip(points, points[1:]):
kwh += (w0 + w1) / 2 * (t1 - t0) / 3600.0 / 1000.0
return kwh
Trapezoidal integration, using each frame's actual timestamp delta rather than assuming a fixed 5 minutes between samples. Deye occasionally returns frames with gaps, and assuming a fixed cadence there quietly overcounts or undercounts energy across the gap. Using the real delta makes the integration correct even when the samples are not evenly spaced.
Timestamps come back as UTC epoch seconds. Everything shown in the report gets converted once, on the way out:
PANAMA_TZ = timezone(timedelta(hours=-5))
def _format_local_time(unix_ts: float) -> str:
return datetime.fromtimestamp(unix_ts, tz=PANAMA_TZ).strftime("%H:%M")
No daylight saving to worry about here, but the lesson generalizes: convert at the boundary, once, and never pass a naive local time back into a comparison with another epoch value.
Handing it to Bedrock
Once the day's metrics and any anomaly flags are built, the payload goes to Claude on Bedrock as a single invoke_model call:
client = boto3.client("bedrock-runtime", region_name=config.aws_region)
body = json.dumps({
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 1024,
"temperature": 0.3,
"system": SYSTEM_PROMPT,
"messages": [{"role": "user", "content": user_msg}],
})
resp = client.invoke_model(modelId=config.bedrock_model_id, body=body)
result = json.loads(resp["body"].read())
report = result["content"][0]["text"]
The interesting part is not the call, it is the system prompt. Most of it is spent telling the model what not to conclude from the numbers. A couple of examples:
- Peak solar power should never be compared against the array's nameplate rating. Real-world peaks always sit well below STC ratings because of temperature and irradiance, so that comparison looks like underperformance on every single day, including perfectly normal ones.
- A weather model on a 10-25 km grid routinely misses the localized afternoon storms that actually explain a bad day here. So the prompt tells the model to treat the weather block as a way to name a cause the inverter's own numbers already established, never as a reason to tell me production "should" have been higher.
Getting those judgment calls right took more iteration than the API integration did. A model that is technically correct about the numbers but wrong about what they mean produces a report that is worse than no report, because it reads confidently wrong.
If Bedrock is unreachable, the pipeline does not go silent. A plain-text fallback template builds the same report from the same metrics, with none of the narrative, so a Bedrock outage costs polish, not the message:
except Exception as e:
logger.error("Bedrock call failed: %s", e)
return _fallback_report(metrics, anomalies)
What actually lands in Telegram
The report is written in Spanish, because that is the language of the one person who reads it. This is what showed up one morning:
☀️ Solar Daily Brief — 2026-09-14
📊 Resumen solar — 14 de septiembre de 2026
Día nublado con lluvia dispersa durante gran parte de la jornada, según el modelo meteorológico.
⚡ Producción y consumo
• Solar generado: 55.7 kWh
• Consumo total: 47.2 kWh
• Índice de horas-sol: 4.97 h — día intermedio a bueno para temporada lluviosa
• Pico solar: 11,205 W a las 12:10
• Pico de consumo: 9,950 W a las 16:30
• Autosuficiencia: 54%
🔋 Batería
• Carga: 3.7 kWh | Descarga: 4.3 kWh
• SOC: mínimo 29% → máximo 100%
🔌 Red eléctrica
• Importado: 21.7 kWh
• Exportado: 26.0 kWh
📐 Strings (MPPT)
• PV1: 17.24 kWh | pico 4,166 W
• PV2: 19.09 kWh | pico 4,076 W
• PV3: 19.29 kWh | pico 4,227 W
PV1 estuvo un 28% por debajo de las demás en la mañana, patrón de sombra conocido, normal para este sistema. Al mediodía las tres strings estuvieron prácticamente a la par (diferencia máxima de 2.8%), lo que confirma que el arreglo está en buen estado. El array alcanzó el 83.9% del nominal de un string, dentro del rango saludable esperado (82-85%): hardware limpio y sin degradación.
💰 ¿Cuánto costó este día?
Tarifa ENSA BTS 1: B/. 0.17581/kWh
• Sin solar, el día habría costado B/. 8.30
• El solar ahorró B/. 9.06, el día se pagó solo y dejó de más
• Importación: B/. 3.82 | Crédito por exportación: B/. 4.57
• El día no costó nada, dejó un crédito de B/. 0.76 contra la factura del mes
📅 Ciclo de facturación (26/08 al 14/09)
• Balance neto acumulado: 83.9 kWh exportados netos
• Ahorro acumulado: B/. 147.40
• Costo neto energía + cargo fijo: B/. 15.35 estimado (excluye recargos municipales)
✅ Buen día a pesar de las nubes, producción sólida, el sistema cubrió más de la mitad del consumo y terminó en crédito con la red.
Nothing in that message came from a template with blanks to fill in. The morning shading on PV1, the exact percentage the strings agreed on at midday, the specific tariff tier, the running billing-cycle total: all of it comes from the metrics payload, and the model's only job is deciding what to say about numbers it did not choose.
Limits worth saying out loud
One station. Everything here is scoped to a single stationId. A multi-site setup would need the collector to loop over stations, and the anomaly baselines (7-day averages, per-string comparisons) would need to be kept separate per site.
Deye's API has rough edges. The inclusive end date, the 200-with-success:false error shape, the password that has to be pre-hashed. None of this is documented clearly enough to get right on the first try, and I only found the retry-worthy error code by hitting it live.
Grid metering can be wrong, and the code has to know it. The installer's CT for grid import was misplaced for the first weeks, reading roughly 62x too low. That taught me not to trust a sensor just because it is coming from the vendor's own API. GRID_METERING_RELIABLE and a GRID_DATA_VALID_FROM date exist specifically so a bad sensor period does not quietly poison every cost calculation and 7-day baseline that comes after it.
Frames have gaps. The trapezoidal integration handles it correctly, but "correctly" here means "does not lie," not "reconstructs what actually happened during the gap." A big enough gap is still missing data.
Getting a daily solar report was never really about the API call. It was about not trusting any single number until I understood where it came from and what could make it wrong, then building that understanding into the pipeline instead of into a mental note I would forget in a month.
The report that lands in Telegram every morning is the easy part now. Getting the data honest was the actual work.

