[{"data":1,"prerenderedAt":799},["ShallowReactive",2],{"articles-tag-python":3},[4],{"_path":5,"_dir":6,"_draft":7,"_partial":7,"_locale":8,"title":9,"description":10,"date":11,"updated":11,"tags":12,"series":18,"seriesPart":19,"readingTime":20,"cover":8,"body":21,"_type":793,"_id":794,"_source":795,"_file":796,"_stem":797,"_extension":798},"/blog/solar-monitoring-part-1-the-python-build","blog",false,"","DIY Solar Monitoring With a Sungoldpower SPH5048: The Python Build","How I built a custom solar inverter monitoring stack with Python, Modbus RTU, TimescaleDB, and a hand-rolled dashboard — and how a PSU cable killed it.","2026-03-09",[13,14,15,16,17],"solar-tracker","homelab","python","modbus","timescaledb","Solar Monitoring Stack",1,"8 min read",{"type":22,"children":23,"toc":782},"root",[24,41,45,50,55,70,75,80,87,101,106,111,146,152,157,162,167,180,360,365,372,377,396,406,533,543,549,554,633,646,651,657,662,667,672,677,683,691,696,702,707,712,717,723,728,741,746,751,756,759,764,776],{"type":25,"tag":26,"props":27,"children":28},"element","p",{},[29,32,39],{"type":30,"value":31},"text","This is part one of a two-part series on building a DIY solar inverter monitoring system for a Sungoldpower SPH5048. ",{"type":25,"tag":33,"props":34,"children":36},"a",{"href":35},"/blog/solar-monitoring-part-2-the-typescript-rebuild",[37],{"type":30,"value":38},"Part two covers the rebuild",{"type":30,"value":40}," after the whole stack was taken out by a PSU cable mistake.",{"type":25,"tag":42,"props":43,"children":44},"hr",{},[],{"type":25,"tag":26,"props":46,"children":47},{},[48],{"type":30,"value":49},"When I first got my solar setup running, I did what any homelab person does: I wanted numbers. Not just the numbers on the inverter's tiny LCD, but historical numbers, trending numbers, \"why did the battery only charge to 80% yesterday\" numbers.",{"type":25,"tag":26,"props":51,"children":52},{},[53],{"type":30,"value":54},"The Sungoldpower SPH5048 is a 5 kW, 48V hybrid/off-grid inverter with split-phase output. It does a lot of things well. Out-of-the-box monitoring is not one of them. The bundled app is unreliable on a local network, and I had no interest in sending my power data to a cloud I don't control.",{"type":25,"tag":26,"props":56,"children":57},{},[58,60,68],{"type":30,"value":59},"My first attempt at fixing this was the easy path: I paid for ",{"type":25,"tag":33,"props":61,"children":65},{"href":62,"rel":63},"https://solar-assistant.io",[64],"nofollow",[66],{"type":30,"value":67},"Solar Assistant",{"type":30,"value":69}," and ran it on a Raspberry Pi. It was a solid solution for about three months, until the Pi's SD card corrupted and took everything with it. That was the first time I lost all my solar tracking data, and I had nothing running for the next four months while I figured out what to do next.",{"type":25,"tag":26,"props":71,"children":72},{},[73],{"type":30,"value":74},"The answer I landed on was to build my own stack. That way I understood every piece, could fix every piece, and wasn't dependent on anyone else's hardware or subscription.",{"type":25,"tag":26,"props":76,"children":77},{},[78],{"type":30,"value":79},"So I built my own stack. And it worked great, right up until it didn't.",{"type":25,"tag":81,"props":82,"children":84},"h2",{"id":83},"the-hardware-connection",[85],{"type":30,"value":86},"The Hardware Connection",{"type":25,"tag":26,"props":88,"children":89},{},[90,92,99],{"type":30,"value":91},"The SPH5048 exposes a Modbus RTU interface over a USB-RS485 adapter. You plug the adapter into the inverter's RS485 port, plug the other end into a USB port on your server, and the inverter shows up as ",{"type":25,"tag":93,"props":94,"children":96},"code",{"className":95},[],[97],{"type":30,"value":98},"/dev/ttyUSB0",{"type":30,"value":100}," (or similar, depending on your udev rules).",{"type":25,"tag":26,"props":102,"children":103},{},[104],{"type":30,"value":105},"Modbus RTU is a serial protocol. It's old, reliable, and almost entirely undocumented for any specific device unless you happen to have the right PDF. I eventually found a register map for a close enough variant of the SPH5048 and started mapping registers to the metrics I cared about.",{"type":25,"tag":26,"props":107,"children":108},{},[109],{"type":30,"value":110},"What I wanted to track:",{"type":25,"tag":112,"props":113,"children":114},"ul",{},[115,121,126,131,136,141],{"type":25,"tag":116,"props":117,"children":118},"li",{},[119],{"type":30,"value":120},"Battery state of charge (SOC), voltage, current",{"type":25,"tag":116,"props":122,"children":123},{},[124],{"type":30,"value":125},"PV1 power, voltage, current (I have one PV string)",{"type":25,"tag":116,"props":127,"children":128},{},[129],{"type":30,"value":130},"Grid power, voltage, frequency",{"type":25,"tag":116,"props":132,"children":133},{},[134],{"type":30,"value":135},"Load power and apparent power",{"type":25,"tag":116,"props":137,"children":138},{},[139],{"type":30,"value":140},"Inverter temperatures",{"type":25,"tag":116,"props":142,"children":143},{},[144],{"type":30,"value":145},"Inverter state (charging, discharging, grid-tied, island mode)",{"type":25,"tag":81,"props":147,"children":149},{"id":148},"the-poller",[150],{"type":30,"value":151},"The Poller",{"type":25,"tag":26,"props":153,"children":154},{},[155],{"type":30,"value":156},"Before I had a working poller, I had a very much not-working one.",{"type":25,"tag":26,"props":158,"children":159},{},[160],{"type":30,"value":161},"My first instinct was to probe the serial connection to figure out what the inverter was doing. Not structured Modbus reads, just throwing bytes at the port to see what came back. What came back was a voltage anomaly. I had flooded the USB/serial interface badly enough that the inverter briefly took everything connected to it offline. No damage, but it was a genuinely scary few seconds watching my whole solar setup drop.",{"type":25,"tag":26,"props":163,"children":164},{},[165],{"type":30,"value":166},"After consulting with an AI about what I was doing wrong, I learned I had been going about it completely backwards. The inverter expects structured Modbus RTU requests. Not probing, not discovery, just: send the right frame, read the response, move on. Once I understood that, everything got simpler.",{"type":25,"tag":26,"props":168,"children":169},{},[170,172,178],{"type":30,"value":171},"The Python script that read all of this was built around ",{"type":25,"tag":93,"props":173,"children":175},{"className":174},[],[176],{"type":30,"value":177},"pymodbus",{"type":30,"value":179},". A polling loop ran every 5 seconds, reading register blocks from the inverter and writing the results to TimescaleDB.",{"type":25,"tag":181,"props":182,"children":185},"pre",{"code":183,"language":15,"meta":8,"className":184,"style":8},"from pymodbus.client import ModbusSerialClient\n\nclient = ModbusSerialClient(\n    port=\"/dev/ttyUSB0\",\n    baudrate=9600,\n    parity=\"N\",\n    stopbits=1,\n    bytesize=8,\n    timeout=3,\n)\n\ndef read_register(address, count=1, unit=1):\n    result = client.read_input_registers(address, count, slave=unit)\n    if result.isError():\n        # Some registers are holding registers, not input registers\n        result = client.read_holding_registers(address, count, slave=unit)\n    if result.isError():\n        return None\n    return result.registers\n","language-python shiki shiki-themes github-dark",[186],{"type":25,"tag":93,"props":187,"children":188},{"__ignoreMap":8},[189,199,209,218,227,236,245,254,263,272,281,289,298,307,316,325,334,342,351],{"type":25,"tag":190,"props":191,"children":193},"span",{"class":192,"line":19},"line",[194],{"type":25,"tag":190,"props":195,"children":196},{},[197],{"type":30,"value":198},"from pymodbus.client import ModbusSerialClient\n",{"type":25,"tag":190,"props":200,"children":202},{"class":192,"line":201},2,[203],{"type":25,"tag":190,"props":204,"children":206},{"emptyLinePlaceholder":205},true,[207],{"type":30,"value":208},"\n",{"type":25,"tag":190,"props":210,"children":212},{"class":192,"line":211},3,[213],{"type":25,"tag":190,"props":214,"children":215},{},[216],{"type":30,"value":217},"client = ModbusSerialClient(\n",{"type":25,"tag":190,"props":219,"children":221},{"class":192,"line":220},4,[222],{"type":25,"tag":190,"props":223,"children":224},{},[225],{"type":30,"value":226},"    port=\"/dev/ttyUSB0\",\n",{"type":25,"tag":190,"props":228,"children":230},{"class":192,"line":229},5,[231],{"type":25,"tag":190,"props":232,"children":233},{},[234],{"type":30,"value":235},"    baudrate=9600,\n",{"type":25,"tag":190,"props":237,"children":239},{"class":192,"line":238},6,[240],{"type":25,"tag":190,"props":241,"children":242},{},[243],{"type":30,"value":244},"    parity=\"N\",\n",{"type":25,"tag":190,"props":246,"children":248},{"class":192,"line":247},7,[249],{"type":25,"tag":190,"props":250,"children":251},{},[252],{"type":30,"value":253},"    stopbits=1,\n",{"type":25,"tag":190,"props":255,"children":257},{"class":192,"line":256},8,[258],{"type":25,"tag":190,"props":259,"children":260},{},[261],{"type":30,"value":262},"    bytesize=8,\n",{"type":25,"tag":190,"props":264,"children":266},{"class":192,"line":265},9,[267],{"type":25,"tag":190,"props":268,"children":269},{},[270],{"type":30,"value":271},"    timeout=3,\n",{"type":25,"tag":190,"props":273,"children":275},{"class":192,"line":274},10,[276],{"type":25,"tag":190,"props":277,"children":278},{},[279],{"type":30,"value":280},")\n",{"type":25,"tag":190,"props":282,"children":284},{"class":192,"line":283},11,[285],{"type":25,"tag":190,"props":286,"children":287},{"emptyLinePlaceholder":205},[288],{"type":30,"value":208},{"type":25,"tag":190,"props":290,"children":292},{"class":192,"line":291},12,[293],{"type":25,"tag":190,"props":294,"children":295},{},[296],{"type":30,"value":297},"def read_register(address, count=1, unit=1):\n",{"type":25,"tag":190,"props":299,"children":301},{"class":192,"line":300},13,[302],{"type":25,"tag":190,"props":303,"children":304},{},[305],{"type":30,"value":306},"    result = client.read_input_registers(address, count, slave=unit)\n",{"type":25,"tag":190,"props":308,"children":310},{"class":192,"line":309},14,[311],{"type":25,"tag":190,"props":312,"children":313},{},[314],{"type":30,"value":315},"    if result.isError():\n",{"type":25,"tag":190,"props":317,"children":319},{"class":192,"line":318},15,[320],{"type":25,"tag":190,"props":321,"children":322},{},[323],{"type":30,"value":324},"        # Some registers are holding registers, not input registers\n",{"type":25,"tag":190,"props":326,"children":328},{"class":192,"line":327},16,[329],{"type":25,"tag":190,"props":330,"children":331},{},[332],{"type":30,"value":333},"        result = client.read_holding_registers(address, count, slave=unit)\n",{"type":25,"tag":190,"props":335,"children":337},{"class":192,"line":336},17,[338],{"type":25,"tag":190,"props":339,"children":340},{},[341],{"type":30,"value":315},{"type":25,"tag":190,"props":343,"children":345},{"class":192,"line":344},18,[346],{"type":25,"tag":190,"props":347,"children":348},{},[349],{"type":30,"value":350},"        return None\n",{"type":25,"tag":190,"props":352,"children":354},{"class":192,"line":353},19,[355],{"type":25,"tag":190,"props":356,"children":357},{},[358],{"type":30,"value":359},"    return result.registers\n",{"type":25,"tag":26,"props":361,"children":362},{},[363],{"type":30,"value":364},"That fallback between input registers (function code 0x04) and holding registers (function code 0x03) turned out to be load-bearing. The SPH5048 uses both, and the register map doesn't always tell you which is which. You have to try one and fall back to the other.",{"type":25,"tag":366,"props":367,"children":369},"h3",{"id":368},"the-modbus-quirks-nobody-warned-me-about",[370],{"type":30,"value":371},"The Modbus Quirks Nobody Warned Me About",{"type":25,"tag":26,"props":373,"children":374},{},[375],{"type":30,"value":376},"Modbus RTU sounds simple on paper. In practice, it has opinions.",{"type":25,"tag":26,"props":378,"children":379},{},[380,386,388,394],{"type":25,"tag":381,"props":382,"children":383},"strong",{},[384],{"type":30,"value":385},"Not every documented register is implemented.",{"type":30,"value":387}," The register map listed PV2 registers and a set of P02 area addresses. When I queried them, the inverter returned ",{"type":25,"tag":93,"props":389,"children":391},{"className":390},[],[392],{"type":30,"value":393},"Illegal data address",{"type":30,"value":395}," errors. They just don't exist on this hardware revision, even though they're in the spec. I had to build a hardcoded allowlist of registers that actually work.",{"type":25,"tag":26,"props":397,"children":398},{},[399,404],{"type":25,"tag":381,"props":400,"children":401},{},[402],{"type":30,"value":403},"Reading too many registers in one request fails.",{"type":30,"value":405}," Modbus has a limit on how many registers you can read in a single request, but the real constraint here was more subtle: the inverter rejects requests that span non-contiguous register areas, even if the span is within spec limits. The solution was to group only contiguous registers into minimal read blocks and issue one request per block.",{"type":25,"tag":181,"props":407,"children":409},{"code":408,"language":15,"meta":8,"className":184,"style":8},"def group_contiguous(registers):\n    \"\"\"Group registers into contiguous blocks for efficient batch reads.\"\"\"\n    if not registers:\n        return []\n    sorted_regs = sorted(registers)\n    blocks = []\n    start = sorted_regs[0]\n    prev = sorted_regs[0]\n    for reg in sorted_regs[1:]:\n        if reg != prev + 1:\n            blocks.append((start, prev - start + 1))\n            start = reg\n        prev = reg\n    blocks.append((start, prev - start + 1))\n    return blocks\n",[410],{"type":25,"tag":93,"props":411,"children":412},{"__ignoreMap":8},[413,421,429,437,445,453,461,469,477,485,493,501,509,517,525],{"type":25,"tag":190,"props":414,"children":415},{"class":192,"line":19},[416],{"type":25,"tag":190,"props":417,"children":418},{},[419],{"type":30,"value":420},"def group_contiguous(registers):\n",{"type":25,"tag":190,"props":422,"children":423},{"class":192,"line":201},[424],{"type":25,"tag":190,"props":425,"children":426},{},[427],{"type":30,"value":428},"    \"\"\"Group registers into contiguous blocks for efficient batch reads.\"\"\"\n",{"type":25,"tag":190,"props":430,"children":431},{"class":192,"line":211},[432],{"type":25,"tag":190,"props":433,"children":434},{},[435],{"type":30,"value":436},"    if not registers:\n",{"type":25,"tag":190,"props":438,"children":439},{"class":192,"line":220},[440],{"type":25,"tag":190,"props":441,"children":442},{},[443],{"type":30,"value":444},"        return []\n",{"type":25,"tag":190,"props":446,"children":447},{"class":192,"line":229},[448],{"type":25,"tag":190,"props":449,"children":450},{},[451],{"type":30,"value":452},"    sorted_regs = sorted(registers)\n",{"type":25,"tag":190,"props":454,"children":455},{"class":192,"line":238},[456],{"type":25,"tag":190,"props":457,"children":458},{},[459],{"type":30,"value":460},"    blocks = []\n",{"type":25,"tag":190,"props":462,"children":463},{"class":192,"line":247},[464],{"type":25,"tag":190,"props":465,"children":466},{},[467],{"type":30,"value":468},"    start = sorted_regs[0]\n",{"type":25,"tag":190,"props":470,"children":471},{"class":192,"line":256},[472],{"type":25,"tag":190,"props":473,"children":474},{},[475],{"type":30,"value":476},"    prev = sorted_regs[0]\n",{"type":25,"tag":190,"props":478,"children":479},{"class":192,"line":265},[480],{"type":25,"tag":190,"props":481,"children":482},{},[483],{"type":30,"value":484},"    for reg in sorted_regs[1:]:\n",{"type":25,"tag":190,"props":486,"children":487},{"class":192,"line":274},[488],{"type":25,"tag":190,"props":489,"children":490},{},[491],{"type":30,"value":492},"        if reg != prev + 1:\n",{"type":25,"tag":190,"props":494,"children":495},{"class":192,"line":283},[496],{"type":25,"tag":190,"props":497,"children":498},{},[499],{"type":30,"value":500},"            blocks.append((start, prev - start + 1))\n",{"type":25,"tag":190,"props":502,"children":503},{"class":192,"line":291},[504],{"type":25,"tag":190,"props":505,"children":506},{},[507],{"type":30,"value":508},"            start = reg\n",{"type":25,"tag":190,"props":510,"children":511},{"class":192,"line":300},[512],{"type":25,"tag":190,"props":513,"children":514},{},[515],{"type":30,"value":516},"        prev = reg\n",{"type":25,"tag":190,"props":518,"children":519},{"class":192,"line":309},[520],{"type":25,"tag":190,"props":521,"children":522},{},[523],{"type":30,"value":524},"    blocks.append((start, prev - start + 1))\n",{"type":25,"tag":190,"props":526,"children":527},{"class":192,"line":318},[528],{"type":25,"tag":190,"props":529,"children":530},{},[531],{"type":30,"value":532},"    return blocks\n",{"type":25,"tag":26,"props":534,"children":535},{},[536,541],{"type":25,"tag":381,"props":537,"children":538},{},[539],{"type":30,"value":540},"The baud rate matters more than you'd think.",{"type":30,"value":542}," At 9600 baud, a 10-second polling interval is fine. Push it faster and you start seeing timeout errors, especially if anything else on the serial bus is competing. I left it at 5 seconds and never had issues.",{"type":25,"tag":81,"props":544,"children":546},{"id":545},"the-database-timescaledb",[547],{"type":30,"value":548},"The Database: TimescaleDB",{"type":25,"tag":26,"props":550,"children":551},{},[552],{"type":30,"value":553},"I used TimescaleDB for storage, which is a PostgreSQL extension that turns a regular table into a time-series hypertable. The schema was intentionally simple:",{"type":25,"tag":181,"props":555,"children":559},{"code":556,"language":557,"meta":8,"className":558,"style":8},"CREATE TABLE inverter_metrics (\n    ts        TIMESTAMPTZ NOT NULL,\n    key       TEXT        NOT NULL,\n    value     DOUBLE PRECISION\n);\n\nSELECT create_hypertable('inverter_metrics', 'ts');\n\nCREATE INDEX ON inverter_metrics (key, ts DESC);\n","sql","language-sql shiki shiki-themes github-dark",[560],{"type":25,"tag":93,"props":561,"children":562},{"__ignoreMap":8},[563,571,579,587,595,603,610,618,625],{"type":25,"tag":190,"props":564,"children":565},{"class":192,"line":19},[566],{"type":25,"tag":190,"props":567,"children":568},{},[569],{"type":30,"value":570},"CREATE TABLE inverter_metrics (\n",{"type":25,"tag":190,"props":572,"children":573},{"class":192,"line":201},[574],{"type":25,"tag":190,"props":575,"children":576},{},[577],{"type":30,"value":578},"    ts        TIMESTAMPTZ NOT NULL,\n",{"type":25,"tag":190,"props":580,"children":581},{"class":192,"line":211},[582],{"type":25,"tag":190,"props":583,"children":584},{},[585],{"type":30,"value":586},"    key       TEXT        NOT NULL,\n",{"type":25,"tag":190,"props":588,"children":589},{"class":192,"line":220},[590],{"type":25,"tag":190,"props":591,"children":592},{},[593],{"type":30,"value":594},"    value     DOUBLE PRECISION\n",{"type":25,"tag":190,"props":596,"children":597},{"class":192,"line":229},[598],{"type":25,"tag":190,"props":599,"children":600},{},[601],{"type":30,"value":602},");\n",{"type":25,"tag":190,"props":604,"children":605},{"class":192,"line":238},[606],{"type":25,"tag":190,"props":607,"children":608},{"emptyLinePlaceholder":205},[609],{"type":30,"value":208},{"type":25,"tag":190,"props":611,"children":612},{"class":192,"line":247},[613],{"type":25,"tag":190,"props":614,"children":615},{},[616],{"type":30,"value":617},"SELECT create_hypertable('inverter_metrics', 'ts');\n",{"type":25,"tag":190,"props":619,"children":620},{"class":192,"line":256},[621],{"type":25,"tag":190,"props":622,"children":623},{"emptyLinePlaceholder":205},[624],{"type":30,"value":208},{"type":25,"tag":190,"props":626,"children":627},{"class":192,"line":265},[628],{"type":25,"tag":190,"props":629,"children":630},{},[631],{"type":30,"value":632},"CREATE INDEX ON inverter_metrics (key, ts DESC);\n",{"type":25,"tag":26,"props":634,"children":635},{},[636,638,644],{"type":30,"value":637},"Every metric stored as a ",{"type":25,"tag":93,"props":639,"children":641},{"className":640},[],[642],{"type":30,"value":643},"(timestamp, key, value)",{"type":30,"value":645}," row. Not the most query-friendly schema for wide tables, but simple to write to and easy to add new metrics without migrations. The inverter had about 30 metrics, so the data volume was manageable: roughly 360 rows every 30 seconds, about 43,000 rows per hour.",{"type":25,"tag":26,"props":647,"children":648},{},[649],{"type":30,"value":650},"TimescaleDB's automatic chunk management and compression made this basically zero-maintenance. I turned on chunk compression after 7 days and it kept the database small without any application-level work.",{"type":25,"tag":81,"props":652,"children":654},{"id":653},"the-api-and-dashboard",[655],{"type":30,"value":656},"The API and Dashboard",{"type":25,"tag":26,"props":658,"children":659},{},[660],{"type":30,"value":661},"Rather than expose TimescaleDB directly to the browser, I put a lightweight TypeScript REST API in front of it. It handled current readings, historical data over a window, and daily/hourly aggregates. Nothing fancy, just a thin layer between the database and the browser.",{"type":25,"tag":26,"props":663,"children":664},{},[665],{"type":30,"value":666},"The dashboard was hand-built HTML, CSS, and vanilla JavaScript. Power flow diagram in the center, SOC gauge on the left, historical charts at the bottom using Chart.js. It looked exactly like what it was: a personal tool someone built over a few weekends.",{"type":25,"tag":26,"props":668,"children":669},{},[670],{"type":30,"value":671},"Building it was genuinely enjoyable. Watching live SOC numbers tick up as the sun came over the panels, seeing the power flow diagram animate between charging and discharging — that was satisfying in a way that a commercial monitoring app would never be, because I made all of it.",{"type":25,"tag":26,"props":673,"children":674},{},[675],{"type":30,"value":676},"Maintaining it was less enjoyable. Every time I wanted to add a metric or change a chart, I was wading back into three layers of code: the schema, the API endpoint, and the dashboard component. Nothing was hard individually. Together, it added up.",{"type":25,"tag":81,"props":678,"children":680},{"id":679},"the-stack-visualized",[681],{"type":30,"value":682},"The Stack, Visualized",{"type":25,"tag":181,"props":684,"children":686},{"code":685},"Sungoldpower SPH5048\n        |\n   RS485 / USB\n        |\n  Python Poller (pymodbus)\n  [10-second poll]\n        |\n  TimescaleDB (PostgreSQL)\n        |\n  TypeScript REST API\n        |\n  Custom HTML/JS Dashboard\n",[687],{"type":25,"tag":93,"props":688,"children":689},{"__ignoreMap":8},[690],{"type":30,"value":685},{"type":25,"tag":26,"props":692,"children":693},{},[694],{"type":30,"value":695},"It was simple at the architecture level. Everything was custom at the implementation level.",{"type":25,"tag":81,"props":697,"children":699},{"id":698},"what-worked",[700],{"type":30,"value":701},"What Worked",{"type":25,"tag":26,"props":703,"children":704},{},[705],{"type":30,"value":706},"The setup ran well. The poller reconnected automatically if the serial connection dropped. TimescaleDB never complained. I had about 10-11 days of 10-second resolution solar data and could query any of it.",{"type":25,"tag":26,"props":708,"children":709},{},[710],{"type":30,"value":711},"I could see exactly when the battery hit 100% and the inverter shifted to grid export. I could track the effect of season changes on daily solar yield. I could see the load jump when the HVAC turned on.",{"type":25,"tag":26,"props":713,"children":714},{},[715],{"type":30,"value":716},"The thing I wanted from the beginning, continuous visibility into my own power system, was working.",{"type":25,"tag":81,"props":718,"children":720},{"id":719},"the-loss",[721],{"type":30,"value":722},"The Loss",{"type":25,"tag":26,"props":724,"children":725},{},[726],{"type":30,"value":727},"Then I killed it myself.",{"type":25,"tag":26,"props":729,"children":730},{},[731,733,739],{"type":30,"value":732},"While doing a server upgrade, I mixed a Corsair SATA power cable with an EVGA PSU. The physical connector fits. The pinouts do not match. I wrote an entire post about that incident ",{"type":25,"tag":33,"props":734,"children":736},{"href":735},"/blog/well-i-embarrassed-myself-even-sooner-than-expected-a-modular-psu-cables-tale",[737],{"type":30,"value":738},"here",{"type":30,"value":740},", but the short version is: the mismatch sent wrong voltages to the drives and that was that.",{"type":25,"tag":26,"props":742,"children":743},{},[744],{"type":30,"value":745},"The server came back up. The custom code did not. I had pushed exactly nothing to a repository. The Python poller, the TypeScript API, the dashboard templates, the Chart.js configuration — all of it was gone. I could recreate the TimescaleDB schema from memory, but the application code existed only on that machine.",{"type":25,"tag":26,"props":747,"children":748},{},[749],{"type":30,"value":750},"Second time losing solar monitoring data. This one stung more because I built it myself.",{"type":25,"tag":26,"props":752,"children":753},{},[754],{"type":30,"value":755},"If you have custom code sitting only on a local machine, please go push it somewhere right now. I'll wait.",{"type":25,"tag":42,"props":757,"children":758},{},[],{"type":25,"tag":26,"props":760,"children":761},{},[762],{"type":30,"value":763},"The good news is it forced a rethink. The rebuild wasn't just a restore operation. It was a chance to reconsider what the original stack was actually costing me to run and make different choices.",{"type":25,"tag":26,"props":765,"children":766},{},[767,769,774],{"type":30,"value":768},"That's what ",{"type":25,"tag":33,"props":770,"children":771},{"href":35},[772],{"type":30,"value":773},"part two is about",{"type":30,"value":775},".",{"type":25,"tag":777,"props":778,"children":779},"style",{},[780],{"type":30,"value":781},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":8,"searchDepth":201,"depth":201,"links":783},[784,785,788,789,790,791,792],{"id":83,"depth":201,"text":86},{"id":148,"depth":201,"text":151,"children":786},[787],{"id":368,"depth":211,"text":371},{"id":545,"depth":201,"text":548},{"id":653,"depth":201,"text":656},{"id":679,"depth":201,"text":682},{"id":698,"depth":201,"text":701},{"id":719,"depth":201,"text":722},"markdown","content:blog:solar-monitoring-part-1-the-python-build.md","content","blog/solar-monitoring-part-1-the-python-build.md","blog/solar-monitoring-part-1-the-python-build","md",1774200455362]