SLOPSHOPPER

wetter-strom

Seitenfenster mit Berliner Wetter (DWD-Daten) und Strommarkt Deutschland (SMARD)

newpanecommandnetworktimer
v0.1.0no licenseupdated 2026-10-24ronnyschumann1983-boop/claude-mod-wetter-strom
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wetter-strom
│ ┃ Wetter & Strom Berlin ✕ › fix the failing auth test and add an audit log call │ ┃ Wetter Berlin │ ┃ keine Wetterdaten ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Strommarkt Deutschland ⏺ Update(src/auth.ts) │ ┃ keine Strommarktdaten ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Wetter: HTTP 0 bei api.brightsky.dev ⎿ 3 pass, 1 fail │ ┃ Strom: HTTP 0 bei www.smard.de │ ┃ Stand 10:53 · alle 15 Min. · DWD-Daten über ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Bright Sky, SMARD │ ┃ [ Aktualisieren ] ✻ Worked for 42s · done 4:20 PM │ │ › /wetter-strom │ ⎿ wetter-strom: Fenster „Wetter & Strom Berlin“ geöffnet. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Wetter & Strom Berlin
Wetter Berlin keine Wetterdaten Strommarkt Deutschland keine Strommarktdaten Wetter: HTTP 0 bei api.brightsky.dev Strom: HTTP 0 bei www.smard.de Stand 10:53 · alle 15 Min. · DWD-Daten über Bright Sky, SMARD [ Aktualisieren ]
README

Wetter & Strom Berlin

Ein Mod für Claude Code. Er öffnet neben dem Chat ein Seitenfenster mit dem aktuellen Wetter in Berlin und dem Strommarkt in Deutschland und aktualisiert es alle 15 Minuten.

Was das Fenster zeigt

Wetter Berlin: Temperatur, Himmel, Wind und Böen, Regen der letzten Stunde, Luftfeuchte und die amtlichen Warnungen des Deutschen Wetterdienstes für Berlin-Mitte. Die Daten stammen vom DWD und werden über Bright Sky abgefragt.

Strommarkt Deutschland: Börsenpreis der laufenden Stunde in ct/kWh, die günstigste und die teuerste Stunde des Tages, der Anteil der Erneuerbaren an der Erzeugung und die fünf größten Quellen. Die Daten stammen von SMARD, der Plattform der Bundesnetzagentur. Der Preis ist der Großhandelspreis am Day-Ahead-Markt, nicht der Haushaltstarif. Die Erzeugung erscheint dort mit etwa zwei Stunden Verzug.

Beide Quellen sind kostenlos und brauchen keinen Schlüssel.

Installieren

In einer Claude-Code-Sitzung im Terminal:

/plugin install wetter-strom --marketplace ronnyschumann1983-boop/claude-mod-wetter-strom

Danach y, um den Marketplace hinzuzufügen, und mit Enter den Bereich wählen.

Aus einer Shell:

claude plugin install wetter-strom --marketplace ronnyschumann1983-boop/claude-mod-wetter-strom

In der Desktop-App, wo /plugin nicht läuft: auf + neben dem Eingabefeld klicken, dann Plugins, Add marketplace mit ronnyschumann1983-boop/claude-mod-wetter-strom, dann Add plugin.

Benutzen

In einem Terminal ab 144 Zeichen Breite öffnet sich das Fenster beim Start von selbst. Sonst mit /wetter-strom. Der Knopf „Aktualisieren“ lädt die Daten sofort neu.

Source 2 files
hooks/register.tsx 264 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Hour, Power, Snapshot, Weather } from '../types'
5
6const PANE = 'wetter-strom'
7const TITLE = 'Wetter & Strom Berlin'
8const GEO = 'lat=52.52&lon=13.40' // Berlin-Mitte
9const EVERY = 15 * 60 * 1000
10const HOUR = 3600 * 1000
11const SMARD = 'https://www.smard.de/app/chart_data'
12
13// SMARD-Filter fuer die Erzeugung (MWh je Stunde), am 24.10.2026 einzeln geprueft
14const SOURCES = [
15  { id: 4067, name: 'Wind an Land', ee: true },
16  { id: 1225, name: 'Wind auf See', ee: true },
17  { id: 4068, name: 'Photovoltaik', ee: true },
18  { id: 4066, name: 'Biomasse', ee: true },
19  { id: 1226, name: 'Wasserkraft', ee: true },
20  { id: 1228, name: 'Sonst. Erneuerbare', ee: true },
21  { id: 1223, name: 'Braunkohle', ee: false },
22  { id: 4069, name: 'Steinkohle', ee: false },
23  { id: 4071, name: 'Erdgas', ee: false },
24  { id: 4070, name: 'Pumpspeicher', ee: false },
25  { id: 1227, name: 'Sonst. Konventionelle', ee: false },
26]
27
28const SKY: Record<string, string> = {
29  'clear-day': 'klar',
30  'clear-night': 'klar',
31  'partly-cloudy-day': 'teils bewölkt',
32  'partly-cloudy-night': 'teils bewölkt',
33  cloudy: 'bedeckt',
34  fog: 'Nebel',
35  wind: 'windig',
36  rain: 'Regen',
37  sleet: 'Schneeregen',
38  snow: 'Schnee',
39  hail: 'Hagel',
40  thunderstorm: 'Gewitter',
41}
42
43const snap = atom({ plugin: 'wetter-strom', key: 'snap' } as const, null, {
44  shape: 'snap-1',
45})
46
47// Berliner Ortszeit ohne Intl: Sommerzeit vom letzten Sonntag im Maerz bis zum
48// letzten Sonntag im Oktober, jeweils 01:00 UTC
49const lastSunday = (year: number, month: number) => {
50  const d = new Date(Date.UTC(year, month + 1, 0, 1))
51  d.setUTCDate(d.getUTCDate() - d.getUTCDay())
52  return d.getTime()
53}
54const berlin = (ms: number) => {
55  const y = new Date(ms).getUTCFullYear()
56  const summer = ms >= lastSunday(y, 2) && ms < lastSunday(y, 9)
57  return new Date(ms + (summer ? 2 : 1) * HOUR)
58}
59const two = (n: number) => String(n).padStart(2, '0')
60const hhmm = (ms: number) => {
61  const d = berlin(ms)
62  return `${two(d.getUTCHours())}:${two(d.getUTCMinutes())}`
63}
64const day = (ms: number) => berlin(ms).toISOString().slice(0, 10)
65const de = (n: number, digits = 1) => n.toFixed(digits).replace('.', ',')
66const or = (n: number | null | undefined, digits: number, unit: string) =>
67  n === null || n === undefined ? '–' : `${de(n, digits)} ${unit}`
68const ct = (h: Hour) => `${de(h.value / 10)} ct`
69
70const json = async ($: EngineInterface, url: string) => {
71  const r = await $.http.fetch(url)
72  if (!r.ok) throw new Error(`HTTP ${r.status} bei ${url.split('/')[2]}`)
73  return JSON.parse(r.text)
74}
75
76type Point = [number, number | null]
77const filled = (s: Point[]) => s.filter((p): p is [number, number] => p[1] !== null)
78const chunk = async ($: EngineInterface, id: number, region: string, ts: number) =>
79  filled((await json($, `${SMARD}/${id}/${region}/${id}_${region}_hour_${ts}.json`)).series)
80
81// Neuester Wochenblock mit Werten; montags frueh ist der neue noch leer
82const latest = async ($: EngineInterface, id: number, index: number[]) => {
83  for (const ts of index.slice(-2).reverse()) {
84    const s = await chunk($, id, 'DE', ts)
85    if (s.length) return s
86  }
87  return []
88}
89
90const loadWeather = async ($: EngineInterface): Promise<Weather> => {
91  const [now, warn] = await Promise.all([
92    json($, `https://api.brightsky.dev/current_weather?${GEO}`),
93    json($, `https://api.brightsky.dev/alerts?${GEO}`),
94  ])
95  const w = now.weather
96  return {
97    at: Date.parse(w.timestamp),
98    station: now.sources?.[0]?.station_name ?? '?',
99    temp: w.temperature ?? null,
100    sky: SKY[w.icon] ?? w.icon ?? '?',
101    wind: w.wind_speed_10 ?? null,
102    gust: w.wind_gust_speed_10 ?? null,
103    rain: w.precipitation_60 ?? null,
104    humidity: w.relative_humidity ?? null,
105    alerts: (warn.alerts ?? []).map(
106      (a: { headline_de: string; expires: string }) =>
107        `${a.headline_de} bis ${hhmm(Date.parse(a.expires))}`,
108    ),
109  }
110}
111
112const loadPower = async ($: EngineInterface, now: number): Promise<Power> => {
113  // Die Wochenblock-Zeitstempel sind fuer Preis (DE-LU) und Erzeugung (DE) gleich
114  const index: number[] = (await json($, `${SMARD}/4169/DE-LU/index_hour.json`)).timestamps
115  // Preis aus beiden Bloecken: sonntags nachmittags steht Montag schon im neuen
116  const prices = (
117    await Promise.all(index.slice(-2).map(ts => chunk($, 4169, 'DE-LU', ts)))
118  ).flat()
119  const hour = ([at, value]: [number, number]): Hour => ({ at, value })
120  const current = prices.find(([at]) => at <= now && now < at + HOUR)
121  const today = prices.filter(([at]) => day(at) === day(now)).sort((a, b) => a[1] - b[1])
122
123  const gen = await Promise.all(SOURCES.map(s => latest($, s.id, index)))
124  // Juengste Stunde, die alle liefernden Quellen schon haben
125  const ends = gen.map(s => s.at(-1)?.[0]).filter((t): t is number => t !== undefined)
126  const mixAt = ends.length ? Math.min(...ends) : 0
127  const mix = SOURCES.map((s, i) => ({
128    ...s,
129    mw: gen[i]?.find(([at]) => at === mixAt)?.[1] ?? 0,
130  }))
131  const total = mix.reduce((n, m) => n + m.mw, 0)
132  const ee = mix.reduce((n, m) => n + (m.ee ? m.mw : 0), 0)
133
134  return {
135    price: current ? hour(current) : null,
136    cheapest: today[0] ? hour(today[0]) : null,
137    dearest: today.at(-1) ? hour(today.at(-1)!) : null,
138    mixAt,
139    eeShare: total > 0 ? ee / total : null,
140    mix: mix
141      .filter(m => m.mw > 0)
142      .sort((a, b) => b.mw - a.mw)
143      .map(({ name, mw }) => ({ name, mw })),
144  }
145}
146
147let isBusy = false
148const refresh = async ($: EngineInterface) => {
149  if (isBusy) return
150  isBusy = true
151  try {
152    const now = await $.clock.now()
153    const [w, p] = await Promise.allSettled([loadWeather($), loadPower($, now)])
154    const errors = [
155      w.status === 'rejected' ? `Wetter: ${w.reason?.message ?? w.reason}` : '',
156      p.status === 'rejected' ? `Strom: ${p.reason?.message ?? p.reason}` : '',
157    ].filter(Boolean)
158    await update($, snap, (old): Snapshot => ({
159      // Ein Ausfall behaelt den letzten guten Stand
160      weather: w.status === 'fulfilled' ? w.value : old?.weather ?? null,
161      power: p.status === 'fulfilled' ? p.value : old?.power ?? null,
162      fetchedAt: now,
163      errors,
164    }))
165  } finally {
166    isBusy = false
167  }
168}
169
170export const register: Register = on => {
171  on('session.start', async ($, e, next) => {
172    await $.command.register({
173      name: 'wetter-strom',
174      description: 'Wetter Berlin und Strommarkt im Seitenfenster',
175    })
176    void $.ui.open({ id: PANE, title: TITLE })
177    void refresh($)
178    $.clock.every(EVERY, () => void refresh($))
179
180    return next(e)
181  })
182
183  on('command.run', { command: 'wetter-strom' }, async $ => {
184    await $.ui.open({ id: PANE, title: TITLE })
185    await refresh($)
186
187    return { text: 'Fenster „Wetter & Strom Berlin“ geöffnet.' }
188  })
189
190  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
191    const { Box, Button, Text } = $.ui.resolve(e)
192    const s = await read($, snap)
193
194    if (!s) {
195      return <Text dimColor>Lade Daten …</Text>
196    }
197
198    const { weather: w, power: p } = s
199
200    return (
201      <Box flexDirection="column">
202        <Text weight="bold">Wetter Berlin</Text>
203        {w ? (
204          <Box flexDirection="column">
205            <Text dimColor>
206              DWD-Station {w.station}, {hhmm(w.at)} Uhr
207            </Text>
208            <Text>
209              {or(w.temp, 1, '°C')} · {w.sky}
210            </Text>
211            <Text>
212              Wind {or(w.wind, 0, 'km/h')}, Böen {or(w.gust, 0, 'km/h')}
213            </Text>
214            <Text>
215              Regen 1 h {or(w.rain, 1, 'mm')} · Feuchte {or(w.humidity, 0, '%')}
216            </Text>
217            {w.alerts.map(a => (
218              <Text color="warning">⚠ {a}</Text>
219            ))}
220          </Box>
221        ) : (
222          <Text dimColor>keine Wetterdaten</Text>
223        )}
224        <Text> </Text>
225        <Text weight="bold">Strommarkt Deutschland</Text>
226        {p ? (
227          <Box flexDirection="column">
228            <Text>
229              Börsenpreis jetzt{' '}
230              {p.price ? `${ct(p.price)}/kWh (${de(p.price.value, 0)} €/MWh)` : '–'}
231            </Text>
232            {p.cheapest && p.dearest && (
233              <Text>
234                Heute am günstigsten {hhmm(p.cheapest.at)} ({ct(p.cheapest)}), am teuersten{' '}
235                {hhmm(p.dearest.at)} ({ct(p.dearest)})
236              </Text>
237            )}
238            {p.eeShare !== null && (
239              <Text color={p.eeShare >= 0.5 ? 'success' : 'text'}>
240                Erneuerbare {de(p.eeShare * 100, 0)} % der Erzeugung ({hhmm(p.mixAt)} Uhr)
241              </Text>
242            )}
243            {p.mix.slice(0, 5).map(m => (
244              <Text dimColor>
245                {m.name} {de(m.mw / 1000)} GW
246              </Text>
247            ))}
248          </Box>
249        ) : (
250          <Text dimColor>keine Strommarktdaten</Text>
251        )}
252        <Text> </Text>
253        {s.errors.map(err => (
254          <Text color="error">{err}</Text>
255        ))}
256        <Text dimColor>
257          Stand {hhmm(s.fetchedAt)} · alle 15 Min. · DWD-Daten über Bright Sky, SMARD
258        </Text>
259        <Button key="reload" label="Aktualisieren" onPress={() => refresh($)} />
260      </Box>
261    )
262  })
263}
264
types/index.d.ts 36 lines
1export type Weather = {
2  at: number
3  station: string
4  temp: number | null
5  sky: string
6  wind: number | null
7  gust: number | null
8  rain: number | null
9  humidity: number | null
10  alerts: string[]
11}
12
13export type Hour = { at: number; value: number }
14
15export type Power = {
16  price: Hour | null
17  cheapest: Hour | null
18  dearest: Hour | null
19  mixAt: number
20  eeShare: number | null
21  mix: { name: string; mw: number }[]
22}
23
24export type Snapshot = {
25  weather: Weather | null
26  power: Power | null
27  fetchedAt: number
28  errors: string[]
29}
30
31declare module 'claude-code' {
32  interface PluginState {
33    'wetter-strom': { snap: Shaped<Snapshot | null> }
34  }
35}
36