# ZendureEvGuard - User Guide

## What is ZendureEvGuard?

ZendureEvGuard is a background daemon that protects your **Zendure SolarFlow 2400 AC** batteries when your electric vehicle (EV) is charging. It monitors your household power consumption in real time and automatically pauses battery discharge during high-power EV charging sessions, then resumes normal operation once charging ends.

## Prerequisites

- **.NET 9 Runtime** (or SDK if building from source)
- A **Zendure SolarFlow 2400 AC** on your local network (local HTTP API enabled)
- One of the following power meters:
  - **Shelly Pro 3EM** (local HTTP API)
  - **Smappee** (cloud OAuth2 API)

## Installation

Unzip the archive you downloaded wherever you like, for example:

| Platform | Suggested location |
|----------|--------------------|
| **Windows** | `C:\ZendureEvGuard\` |
| **Linux** | `/home/ZendureEvGuard/` |
| **macOS** | `/Applications/ZendureEvGuard/` |

Fill in `appsettings.json` (see **Configuration** below), then run the installer shipped in the
archive:

| Platform | Command |
|----------|---------|
| **Windows** | PowerShell **as administrator**: `powershell -ExecutionPolicy Bypass -File .\install-windows.ps1` |
| **Linux** | `sudo sh install-linux.sh` |
| **macOS** | `sudo sh install-macos.sh` |

The program installs **from that folder**: nothing is copied elsewhere, and the folder you unzipped
is the folder that runs. Re-running the installer upgrades the installation and **never overwrites
your `appsettings.json`**.

To try it without installing, just run the executable: `[P]` pauses the battery, `[R]` resumes it,
`Ctrl+C` twice to stop.

### From source (developers)

```bash
git clone <repository-url>
cd ZendureEvGuard
pwsh ./check.ps1
```

## Licensing & Subscription

ZendureEvGuard uses the **Digital3D "One for All"** subscription model. One subscription unlocks all Digital3D desktop applications.

### Free Trial (30 days)

When you first launch ZendureEvGuard, a **30-day free trial** starts automatically. No account, no credit card, no registration required. You will see a message like:

```
[12:00:00] Trial mode: 30 days remaining. Subscribe at https://digital3d.com/StripeAbo
```

### Purchasing a Subscription

When your trial expires (or before), follow these steps:

1. Go to [https://digital3d.com/StripeAbo](https://digital3d.com/StripeAbo)
2. Create an account or log in
3. Complete the subscription purchase via Stripe (secure payment)
4. Once purchased, go to your account profile on [https://digital3d.com](https://digital3d.com)
5. Find your **Secret Key** (a GUID like `a1b2c3d4-e5f6-7890-abcd-ef1234567890`) — this is displayed in your account settings

### Activating Your Subscription

Add your secret key to the `appsettings.json` file:

```json
"Subscription": {
    "SecretKey": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
```

Then restart ZendureEvGuard. On startup, you will see:

```
[12:00:00] Zendure EV Guard PRO — License valid until 15/04/2026

    ★ Zendure EV Guard PRO — One for All subscription
    ★ License valid until 15/04/2026
    ★ Account: your-username
```

### How License Verification Works

- The license is verified **once per day** against the Digital3D server
- If the server is unreachable (no internet), the application continues working **offline for up to 35 days**
- The license **auto-renews 7 days** before expiration — no manual action needed
- If the subscription expires and is not renewed, the application will stop after a **48-hour grace period**
- If notifications are configured (email, Pushover, or Telegram), you will receive a warning before the application stops

### Protecting Your Secret Key

Your secret key is like a password — and so are your SMTP password, Smappee credentials and
Pushover/Telegram tokens. **The recommended setup is to move them all out of
`appsettings.json`** into an external `.env` file:

1. Create a `.env` file in a secure location:
```
Subscription__SecretKey=a1b2c3d4-e5f6-7890-abcd-ef1234567890
Notification__Password=your-app-password
Smappee__ClientSecret=your-secret
Pushover__ApiToken=your-token
```
Each variable name mirrors the configuration path, with a **double underscore** in place of
the dot.

2. Reference it in `appsettings.json`:
```json
"SecretFile": "/path/to/your/.env"
```

3. Restrict its permissions. On Linux/macOS `appsettings.json` is created as `644` (readable
   by every account on the machine) — the `.env` should be stricter:
```bash
chmod 600 /path/to/your/.env
```

Values from the `.env` **take precedence** over `appsettings.json`: a key present in both is
read from the `.env`.

### Troubleshooting Licensing

| Symptom | Cause | Fix |
|---------|-------|-----|
| "Trial period expired!" | 30-day trial ended | Purchase a subscription at digital3d.com/StripeAbo |
| "Subscription authentication failed" | Invalid or expired secret key | Verify your key on digital3d.com or renew subscription |
| "License expired after 2 consecutive checks" | Subscription not renewed + 48h grace period passed | Renew your subscription on digital3d.com |
| App works but shows "Trial mode" after adding key | Restart required | Stop and restart ZendureEvGuard |

### Usage Telemetry

ZendureEvGuard sends a few usage events to digital3d.com (application launch, battery
pause/resume). Their content is limited to technical indicators: never a device IP address,
file path, token, or power/charge value. If you use a subscription, these events are tied to
your account; without one, they only carry a device identifier. To disable this, set the
environment variable `DIGITAL3D_TELEMETRY_DISABLED=1`.

## Configuration

All settings are in `appsettings.json`. Sensitive values (passwords, API keys, subscription
key) **should** be moved to an external `.env` file referenced by the `SecretFile` setting —
see "Protecting Your Secret Key" above.

### Power Source

Enable **one** of the two power sources. If both are enabled, Shelly takes priority for EV detection. Smappee runs as an independent informational display regardless.

#### Shelly Pro 3EM

```json
"Shelly": {
  "Enabled": true,
  "Host": "192.168.x.x",
  "UseDigestAuth": false,
  "Username": "",
  "Password": ""
}
```

| Setting | Description |
|---------|-------------|
| `Host` | IP address of your Shelly Pro 3EM |
| `UseDigestAuth` | Set `true` if you enabled authentication on the Shelly |
| `Username` / `Password` | Credentials if auth is enabled. Shelly Gen2 firmware only accepts the user `admin` |

> If the Shelly rejects the credentials, ZendureEvGuard **will not start**: it says so in the log,
> sends you a notification, and leaves the battery alone. Without power readings it cannot detect
> anything, and pausing as a precaution over a password would leave the battery off for good.

#### Smappee

```json
"Smappee": {
  "Enabled": true,
  "ClientId": "your-client-id",
  "ClientSecret": "your-secret",
  "Username": "your-email",
  "Password": "your-password",
  "ServiceLocationId": "123456",
  "PollingIntervalSeconds": 30
}
```

| Setting | Description |
|---------|-------------|
| `ClientId` / `ClientSecret` | Smappee developer API credentials |
| `Username` / `Password` | Your Smappee account login |
| `ServiceLocationId` | Found in Smappee dashboard (not the serial number) |
| `PollingIntervalSeconds` | How often to query Smappee cloud API (default: 30s) |

> Shelly and Smappee do not combine: when both are enabled, **Shelly drives detection** and
> Smappee is reduced to its informational display.

### Zendure Device

```json
"ZendureDevice": {
  "Host": "192.168.x.x",
  "Port": 80,
  "DefaultOutputLimit": 2400,
  "DefaultMinSoc": 100,
  "TimeoutSeconds": 10
}
```

| Setting | Description |
|---------|-------------|
| `Host` | IP address of your Zendure SolarFlow hub |
| `Port` | HTTP port (usually `80`) |
| `DefaultOutputLimit` | Fallback output limit in watts when resuming (used if no saved state) |
| `DefaultMinSoc` | Fallback **discharge floor** when resuming (scale: value/10 = %, so `100` = 10%). Only used when the pre-pause value is unknown |
| `TimeoutSeconds` | HTTP request timeout |

> ⚠️ `DefaultMinSoc` is the **discharge floor** (`minSoc`), not the charge ceiling (`socSet`).
> A value near 1000 forbids the battery from discharging at all: putting your charge target
> here (e.g. `950` = 95%) would make the batteries unusable. Startup now rejects anything
> outside the 1-999 range.

### EV Detection

```json
"EvDetection": {
  "PowerThresholdWatts": 5000,
  "ConfirmationReadings": 3,
  "ReleaseReadings": 24,
  "PollingIntervalSeconds": 5,
  "PauseOnSourceUnreachableMinutes": 3
}
```

| Setting | Description |
|---------|-------------|
| `PowerThresholdWatts` | Power consumption above this triggers EV detection |
| `ConfirmationReadings` | How many consecutive readings above threshold to confirm EV charging (3 readings x 5s = 15s) |
| `ReleaseReadings` | How many consecutive readings below threshold to end detection (24 readings x 5s = 120s) |
| `PollingIntervalSeconds` | How often to read power consumption |
| `PauseOnSourceUnreachableMinutes` | Minutes of unreadable power before a **precautionary pause** (default: 3, `0` disables it) |

> **Precautionary pause**: when the meter becomes unreadable (network outage, Shelly down),
> the service is blind and cannot detect a car being plugged in. Past this delay the battery
> is paused as a precaution and you are notified. It resumes on its own as soon as readings
> return and no EV charging is confirmed.

> **Tip**: Set `PowerThresholdWatts` higher than your typical household load but lower than your EV charger's draw. For a 7.4 kW charger, `5000` is a good starting point.

### Email Notifications

```json
"Notification": {
  "Enabled": true,
  "SmtpHost": "smtp.gmail.com",
  "SmtpPort": 587,
  "UseSsl": true,
  "Username": "you@gmail.com",
  "Password": "your-app-password",
  "FromAddress": "evguard@yourdomain.com",
  "ToAddress": "you@gmail.com",
  "RateLimitMinutes": 15
}
```

Set `Enabled: false` to disable notifications entirely. The `RateLimitMinutes` prevents notification spam.

### Telegram Notifications (multi-bot)

Since version 1.2.0, the `Telegram` section accepts a `Bots` list: each bot has its own name,
its own token and its own set of recipients. This lets you, for example, notify 2 people
through a "family" bot and 3 others through a separate "admin" bot.

Every notification is sent to **all** recipients of **all** configured bots: the list is there
to cover several groups, not to route messages by type. If delivery to one recipient fails, the
others are still notified.

```json
"Telegram": {
  "Enabled": true,
  "Bots": [
    {
      "Name": "Family",
      "BotToken": "111111111:AAbotTokenFamily",
      "ChatIds": [ "111111111", "222222222" ]
    },
    {
      "Name": "Admin",
      "BotToken": "222222222:AAbotTokenAdmin",
      "ChatIds": [ "333333333", "444444444", "555555555" ]
    }
  ],
  "RateLimitMinutes": 15
}
```

In this example: the "Family" bot sends to 2 recipients, the "Admin" bot to 3. If sending to
one recipient fails (invalid token, blocked chat, etc.), the other recipients and the other
bots still receive their notification — one isolated failure never blocks the rest.

| Setting | Description |
|---------|-------------|
| `Bots[].Name` | Free-form bot name, shown in logs |
| `Bots[].BotToken` | Telegram bot token (obtained via [@BotFather](https://t.me/BotFather)) |
| `Bots[].ChatIds` | List of chat IDs this bot sends to |

> **Existing installations**: the legacy single-bot form (`BotToken` + `ChatId` at the root of
> the section, the latter accepting several IDs separated by `;`) remains fully supported. It
> is only used when `Bots` is empty or absent — no migration is needed to keep working as-is.

### Monitoring

```json
"Monitoring": {
  "HealthCheckIntervalSeconds": 60,
  "LogStatusIntervalSeconds": 300
}
```

| Setting | Description |
|---------|-------------|
| `HealthCheckIntervalSeconds` | How often to check device connectivity |
| `LogStatusIntervalSeconds` | How often to log a status summary |

### Message language

```json
"Messages": {
  "Language": "en"
}
```

| Value | Effect |
|--------|-------------|
| `"en"` | English (default when the key is absent) |
| `"fr"` | French |

This key lives inside the `Messages` section (not at the root: at the root, Linux's `LANGUAGE`
environment variable would silently replace it). Regional forms (`fr-FR`, `en-GB`)
are accepted. An unknown value never prevents startup: English applies and a warning goes to the
log.

**What is translated**: notifications (email, Telegram, Pushover), the end-of-charge report, leak
and protection-stall messages, and startup refusal reasons.

**What is not**: the **logs** stay in English whatever the language — they are what you read to
diagnose a fault, and support cannot work across as many languages as there are customers. Console
output stays in English too.

### Indicative charging cost

```json
"Energy": {
  "PricePerKwhCents": 28
}
```

| Setting | Description |
|-----------|-------------|
| `PricePerKwhCents` | Price per kWh in **euro cents** (`28` = 0.28 €/kWh). `0` turns the cost display off |

**The shipped default is `28`** (0.28 €/kWh), which is most likely not your tariff: replace it
with yours, or set `0` if you want no amount at all.

The end-of-charge report then shows **two amounts**: one for the energy measured at the meter, and
one for the share attributed to the vehicle.

⚠️ **These amounts are indicative, not a bill.** In particular, if your installation produces
solar power, the share attributed to the vehicle is an **upper bound**: when the house is exporting
just before the charge, the household baseline cannot be subtracted, so the vehicle's share then
includes what the house itself consumed. The report says so explicitly when that happens.

Off-peak / peak tariffs are not supported, and the currency is the euro.

## Running

### Interactive mode

```bash
dotnet run --project src/ZendureEvGuard
```

On startup, the application:
1. Displays a banner
2. Tests connectivity to all configured devices
3. Starts monitoring

### Keyboard controls (interactive mode only)

| Key | Action |
|-----|--------|
| `P` | Manually pause battery output. The pause HOLDS until `R` or shutdown: no automatic path lifts it, not even the meter coming back. A reminder is logged every hour while it lasts, so a forgotten pause does not leave the battery out of service in silence. |
| `R` | Manually resume battery output. **Refused while an EV charge is confirmed**: the protection would pause the battery again on the next cycle, so the daemon says so rather than sending an order that will not stick. Unplug the vehicle, or wait for the charge to end. |
| `Ctrl+C` (x1) | Warning: press again to confirm shutdown |
| `Ctrl+C` (x2) | Graceful shutdown (restores battery if paused) |

### Logs

- **Console**: real-time output with timestamps
- **File**: daily rolling logs in `logs/zendure-evguard-YYYYMMDD.log` (30-day retention)

Log files include severity levels (`INF`, `WRN`, `ERR`) for easy filtering:

```bash
# Linux/WSL
tail -f logs/zendure-evguard-*.log
grep ERR logs/zendure-evguard-*.log
```

### Updates

ZendureEvGuard **does not update itself**. Once an hour it checks whether a newer version exists,
and all it does is tell you. Nothing is downloaded, nothing is replaced, the service is never
restarted behind your back: battery protection keeps running on the installed version until you
decide otherwise.

Two channels, two deliberately different rhythms:

- **The logs**: a reminder **every hour**, on the console and in the file, for as long as the new
  version is not installed. A line that scrolled past an hour ago is a line nobody sees.
- **Notifications** (e-mail, Telegram, Pushover): **one single message per version**. You will not
  be reminded again for that one. The next version sends a new message.

The message carries the changelog, the **direct download link** and the release page. To install:
stop the service, replace the files with those from the archive while **keeping your own
`appsettings.json`**, start it again.

To change this behaviour, in `appsettings.json`:

| Key | Effect |
|---|---|
| `Update:CheckEnabled: false` | no check at all — neither log nor notification |
| `Update:NotifyOnNewVersion: false` | keeps the hourly log reminder, sends no message |

## How It Works

```
┌─────────────┐     ┌──────────────────┐     ┌─────────────────┐
│ Shelly 3EM  │────>│  EvCharging      │────>│  Zendure        │
│ or Smappee  │     │  Detector        │     │  Controller     │
│ (power W)   │     │  (state machine) │     │  (pause/resume) │
└─────────────┘     └──────────────────┘     └─────────────────┘
```

1. **Poll** power consumption every 5 seconds (configurable)
2. **Detect** EV charging using hysteresis (3 confirmations to start, 24 to release)
3. **Pause** battery: set `minSoc=1000` (100%) + `outputLimit=0`
4. **Resume** battery: restore saved `minSoc` and `outputLimit`
5. **Self-heal**: if resume fails, automatically retries every polling cycle
6. **Shutdown**: gracefully restores battery output on exit

### Why minSoc=1000?

The Zendure SolarFlow in Smart CT (Zenki/HEMS) mode continuously overrides `outputLimit` via its CT clamp. Writing `minSoc=1000` (100%) is what holds the discharge back in that mode, and both writes are needed — which is why ZendureEvGuard repeats them every 5 seconds instead of setting them once.

**It is not a lock.** The hub exposes none. Measured on a real charge on 21 August 2026: despite the pause being applied and re-applied, the battery fed up to 2,324 W into the vehicle for short stretches. ZendureEvGuard detects these leaks, re-writes the pause at once, and **tells you what they cost** in the end-of-charge report:

```
Warning: the battery fed the house for 45s despite the pause
(2 episode(s), peak 2324W), costing at least 29 Wh of stored energy.
The daemon detected it and re-applied the pause each time.
```

No such line means the pause held from start to finish.

## Troubleshooting

| Symptom | Cause | Fix |
|---------|-------|-----|
| `UNREACHABLE` at startup | Device not on network or wrong IP | Check IP addresses in config |
| Battery not pausing | Threshold too high | Lower `PowerThresholdWatts` |
| False EV detection | Threshold too low or appliances | Raise `PowerThresholdWatts`, increase `ConfirmationReadings` |
| Battery stuck in pause | Resume failed | App retries automatically; check logs for errors |
| `?` characters in console | Unicode encoding issue | Should be fixed; update to latest version |
| Smappee auth fails | Expired token | Check credentials; token refreshes automatically |
