# catproduc Solar generation sharing. Users push readings from their inverter; each user gets a public or private page with a live power figure, a daily curve and daily totals. Base URL: https://catproduc.ro ## Authentication Every ingest endpoint takes an API key, created by the user at https://catproduc.ro/settings. Keys start with "cp_live_". Send it as any one of: Authorization: Bearer X-Api-Key: X-Pvoutput-Apikey: Rate limit: 600 requests/hour per key; a batch counts as one request. 401 = bad or missing key. 429 = rate limited, with Retry-After in seconds. ## POST /api/v1/readings Accepts a single reading object, a bare array, or {"readings": [...]}. Max 1000 readings per request. Returns 202 {"ok":true,"accepted":N}. Fields (all optional except that ONE of watts / energy_today_* / energy_total_* must be present): at ISO 8601 string, or unix seconds as a number. Omit to use the server's current time. watts instantaneous AC power, integer W, 0..1000000 energy_today_wh energy generated so far TODAY, integer Wh energy_total_wh LIFETIME cumulative energy, integer Wh energy_today_kwh same as energy_today_wh but in kWh (float) energy_total_kwh same as energy_total_wh but in kWh (float) temp_c float, degrees Celsius voltage float, volts Send whichever counter your inverter reports. If you send a today-counter, the day's total is its maximum. If you send only a lifetime counter, the day's total is the span between its first and last value that day. Sending both is fine. Timestamps more than 1 hour in the future are rejected with 400. Timestamps more than 14 days old are rejected with 400. That window is a catch-up allowance for uploaders that were offline, not an import path — bulk historical import is deliberately not offered. import { siteUrl } from "@/lib/site"; Idempotency: a reading is keyed by (user, timestamp). Re-sending the same timestamp overwrites that reading and recomputes the day — it never duplicates. Retrying after a timeout is always safe. curl -X POST https://catproduc.ro/api/v1/readings \ -H "Authorization: Bearer cp_live_xxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"watts": 4210, "energy_today_kwh": 21.5}' ## How the daily total is worked out In order of preference: 1. If you send energy_today_* , the day's total is the maximum value seen. 2. Else if you send energy_total_* , it is max(total) - min(total) within the day. 3. Else, if you send only watts, the power curve is integrated (trapezoidal, partitioned by local day so it never crosses midnight). Those days are flagged energy_estimated and shown as estimates in the UI. A fourth kind of day exists but never comes from readings: one imported through /api/v1/outputs, stored as source='declared'. Readings always beat a declared total. Prefer 1 or 2. A counter is immune to gaps: every reading carries the day's running total, so a dropped sample, a restart, or an uploader that only came up at 10:00 costs nothing. Integration is not — it can only account for time it has samples for. Measured against real curves: integration is within ~1% on complete data and ~3% through a four-hour midday outage (a straight line across a gap is a good guess on a smooth solar curve), but loses 20-28% when the uploader starts late or dies before sunset. Note that an uploader dying before sunset also costs a today-counter the rest of the day; only a lifetime counter survives that, on the next day's first reading. ## POST /api/v1/outputs (historical daily totals) Separate from /api/v1/readings on purpose. Takes bare daily totals with NO recency limit, for importing history from another platform. Disabled per-account by default (403 with an explanation); it is the hook a premium tier would flip. Body: one day, an array, or {"outputs": [...]}. Max 1000 days. Fields: date (YYYY-MM-DD, in the user's timezone), energy_wh OR energy_kwh, optional peak_watts. These days are stored with source='declared' and rendered as unverified — outlined on the chart, prefixed with the approximate sign in tables. A day that already has readings is REFUSED with 409 and the offending dates, never overwritten. If readings later arrive for a declared day, the readings win and the day becomes measured. # Import historical daily totals — no limit on how far back they go. # These days are stored as "declared" and drawn differently from measured ones. # # A day that already has readings is REFUSED (409), never overwritten. curl -X POST https://catproduc.ro/api/v1/outputs \ -H "Authorization: Bearer cp_live_xxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"outputs": [ {"date": "2024-06-01", "energy_kwh": 30.5}, {"date": "2024-06-02", "energy_kwh": 28.1, "peak_watts": 5100} ]}' ## GET /api/v1/me Verifies a key. Returns {"ok":true,"username":...,"timezone":...,"isPublic":...}. Call this first when debugging: it separates a bad key from a bad payload. ## PVOutput compatibility GET|POST /service/r2/addstatus.jsp GET|POST /service/r2/addbatchstatus.jsp Wire-compatible with PVOutput's Add Status API, so existing uploaders work by changing only the host. Parameters d (yyyymmdd), t (hh:mm), v1 (energy Wh), v2 (power W), v5 (temp C), v6 (volts), c1 (cumulative flag: 1 or 2 mean v1 is a lifetime counter). d and t are read in the USER'S timezone, as PVOutput does. System id is accepted and ignored. Responses are plain text: "OK 200: ..." or "Bad request 400: ...". # Already pushing to PVOutput? Change the host and keep everything else. # was: https://pvoutput.org/service/r2/addstatus.jsp # now: https://catproduc.ro/service/r2/addstatus.jsp # # Your catproduc key goes in the X-Pvoutput-Apikey header. The system id is # ignored. d and t are read in YOUR timezone, exactly as PVOutput reads them. curl "https://catproduc.ro/service/r2/addstatus.jsp?d=20260827&t=13:05&v1=18000&v2=4800" \ -H "X-Pvoutput-Apikey: cp_live_xxxxxxxxxxxxxxxxxxxxxxxx" ## Home Assistant The recommended route. No custom component needed — rest_command plus a time_pattern automation. Because Home Assistant already integrates with almost every inverter (SolarEdge, Enphase, Fronius, Huawei, Growatt, Deye, Sungrow, SMA, Victron, GoodWe, SolaX, Shelly EM, Tasmota), this is the only per-inverter work anyone has to do. # configuration.yaml rest_command: catproduc_push: url: "https://catproduc.ro/api/v1/readings" method: POST headers: Authorization: !secret catproduc_key Content-Type: "application/json" payload: >- { "watts": {{ states('sensor.solar_power') | float(0) | round(0) }}, "energy_today_kwh": {{ states('sensor.solar_energy_today') | float(0) }} } # automations.yaml - alias: Push solar to catproduc trigger: - platform: time_pattern # Once a minute. Measured against a real inverter, 5 minutes already # captures 99.4% of the peak, so this is about resolution rather than # accuracy: 1440 points a day instead of 288, which is what makes cloud # edges visible. 60 pushes/hour against a 600/hour limit. minutes: "/1" condition: # Don't push "unknown" while the inverter is asleep. - condition: template value_template: >- {{ states('sensor.solar_power') not in ['unknown', 'unavailable', 'none'] }} action: - service: rest_command.catproduc_push # secrets.yaml catproduc_key: "Bearer cp_live_xxxxxxxxxxxxxxxxxxxxxxxx" ## Timezones Each user has an IANA timezone on their profile (default Europe/Bucharest). Daily totals are bucketed by that local day, not by UTC, and DST transitions are handled by the database. A reading at 21:30Z in June belongs to the next local day in Europe/Bucharest. ## Retention Raw readings are kept 90 days. Daily totals are kept indefinitely. ## Common mistakes - Pushing "unknown"/"unavailable" from a sleeping inverter. Guard the automation with a condition; a non-numeric value is a 400. - Sending a lifetime counter in energy_today_wh. The day's total will be wrong and enormous. Use energy_total_wh, or PVOutput's c1=2. - Assuming a 401 is a payload problem. Check GET /api/v1/me first. - Local wall-clock time sent as if it were UTC. Either send a real ISO 8601 timestamp with an offset, or use the PVOutput endpoint, which expects local time.