Created
April 6, 2026 00:30
-
-
Save andrewhathaway/502a12a7b7ee0b5614c00c9f0202f709 to your computer and use it in GitHub Desktop.
Octopus Energy API Client Script - Generated by Codex
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Octopus Energy + TRMNL example endpoint. | |
| * Article: https://andrewhathaway.net/blog/ambient-cost-display-for-octopus-energy | |
| * Warning: Untested & not-ran. | |
| * | |
| * Deployment targets: | |
| * - Cloudflare Workers: export default with fetch handler is ready to deploy. | |
| * - Deno Deploy: uncomment `Deno.serve(handleRequest);` at the bottom. | |
| * | |
| * Configuration: | |
| * 1) Set values in `CONFIG` directly, or | |
| * 2) Provide environment variables with the same names: | |
| * - OCTOPUS_API_KEY | |
| * - OCTOPUS_ACCOUNT_NUMBER | |
| * - ENDPOINT_AUTH_TOKEN | |
| * - OCTOPUS_GRAPHQL_URL (optional) | |
| * - ENERGY_TIMEZONE (optional, defaults to Europe/London) | |
| * - ENDPOINT_PATH (optional, defaults to /today) | |
| */ | |
| type RawNumber = number | string | null | undefined; | |
| type GraphQLResult<TData> = { | |
| data?: TData; | |
| errors?: Array<{ message: string }>; | |
| }; | |
| type DateTimeParts = { | |
| year: number; | |
| month: number; | |
| day: number; | |
| hour: number; | |
| minute: number; | |
| second: number; | |
| }; | |
| type TelemetryReading = { | |
| readAt: string; | |
| costDeltaWithTax?: RawNumber; | |
| consumptionDelta?: RawNumber; | |
| }; | |
| type UnitRateWindow = { | |
| validFrom: string; | |
| validTo?: string | null; | |
| value?: RawNumber; | |
| }; | |
| type UnitCostModel = number | UnitRateWindow[]; | |
| type EnergyStatistic = { | |
| totalWatts: number; | |
| totalCost: number; | |
| }; | |
| type AppConfig = { | |
| octopusApiKey: string; | |
| octopusAccountNumber: string; | |
| endpointAuthToken: string; | |
| octopusGraphqlUrl: string; | |
| timeZone: string; | |
| endpointPath: string; | |
| }; | |
| type AccountQueryData = { | |
| account?: { | |
| electricityAgreements?: Array<{ | |
| meterPoint?: { | |
| meters?: Array<{ | |
| smartDevices?: Array<{ deviceId?: string | null }> | null; | |
| }> | null; | |
| } | null; | |
| tariff?: { | |
| unitRate?: RawNumber; | |
| dayRate?: RawNumber; | |
| nightRate?: RawNumber; | |
| unitRates?: UnitRateWindow[] | null; | |
| } | null; | |
| }> | null; | |
| gasAgreements?: Array<{ | |
| meterPoint?: { | |
| meters?: Array<{ | |
| smartDevices?: Array<{ deviceId?: string | null }> | null; | |
| }> | null; | |
| } | null; | |
| }> | null; | |
| } | null; | |
| }; | |
| type TokenQueryData = { | |
| obtainKrakenToken?: { | |
| token?: string | null; | |
| } | null; | |
| }; | |
| type TelemetryQueryData = { | |
| elecTelemetry?: TelemetryReading[] | null; | |
| gasTelemetry?: TelemetryReading[] | null; | |
| }; | |
| const DEFAULT_GRAPHQL_URL = "https://api.octopus.energy/v1/graphql/"; | |
| const DEFAULT_TIMEZONE = "Europe/London"; | |
| const DEFAULT_ENDPOINT_PATH = "/today"; | |
| const CONFIG: AppConfig = { | |
| octopusApiKey: getEnv("OCTOPUS_API_KEY") ?? "<YOUR_OCTOPUS_API_KEY>", | |
| octopusAccountNumber: getEnv("OCTOPUS_ACCOUNT_NUMBER") ?? "<YOUR_OCTOPUS_ACCOUNT_NUMBER>", | |
| endpointAuthToken: getEnv("ENDPOINT_AUTH_TOKEN") ?? "<YOUR_ENDPOINT_AUTH_TOKEN>", | |
| octopusGraphqlUrl: getEnv("OCTOPUS_GRAPHQL_URL") ?? DEFAULT_GRAPHQL_URL, | |
| timeZone: getEnv("ENERGY_TIMEZONE") ?? DEFAULT_TIMEZONE, | |
| endpointPath: getEnv("ENDPOINT_PATH") ?? DEFAULT_ENDPOINT_PATH, | |
| }; | |
| const OBTAIN_KRAKEN_TOKEN_MUTATION = ` | |
| mutation ObtainKrakenToken($input: ObtainJSONWebTokenInput!) { | |
| obtainKrakenToken(input: $input) { | |
| token | |
| } | |
| } | |
| `; | |
| const ACCOUNT_INFORMATION_QUERY = ` | |
| query($accountNumber: String!) { | |
| account(accountNumber: $accountNumber) { | |
| electricityAgreements(active: true) { | |
| meterPoint { | |
| meters { | |
| smartDevices { | |
| deviceId | |
| } | |
| } | |
| } | |
| tariff { | |
| ... on StandardTariff { | |
| unitRate | |
| } | |
| ... on DayNightTariff { | |
| dayRate | |
| nightRate | |
| } | |
| ... on HalfHourlyTariff { | |
| unitRates { | |
| validFrom | |
| validTo | |
| value | |
| } | |
| } | |
| ... on PrepayTariff { | |
| unitRate | |
| } | |
| } | |
| } | |
| gasAgreements(active: true) { | |
| meterPoint { | |
| meters { | |
| smartDevices { | |
| deviceId | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| `; | |
| const TELEMETRY_QUERY = ` | |
| query( | |
| $elecDeviceId: String! | |
| $gasDeviceId: String! | |
| $startUtc: DateTime! | |
| $endUtc: DateTime! | |
| ) { | |
| elecTelemetry: smartMeterTelemetry( | |
| deviceId: $elecDeviceId | |
| grouping: HALF_HOURLY | |
| start: $startUtc | |
| end: $endUtc | |
| ) { | |
| readAt | |
| costDeltaWithTax | |
| consumptionDelta | |
| } | |
| gasTelemetry: smartMeterTelemetry( | |
| deviceId: $gasDeviceId | |
| grouping: HALF_HOURLY | |
| start: $startUtc | |
| end: $endUtc | |
| ) { | |
| readAt | |
| costDeltaWithTax | |
| consumptionDelta | |
| } | |
| } | |
| `; | |
| export async function handleRequest(request: Request): Promise<Response> { | |
| const url = new URL(request.url); | |
| if (url.pathname !== CONFIG.endpointPath) { | |
| return json({ error: "Not found" }, 404); | |
| } | |
| if (request.method !== "GET") { | |
| return json({ error: "Method not allowed" }, 405, { allow: "GET" }); | |
| } | |
| if (!isAuthorized(request, CONFIG.endpointAuthToken)) { | |
| return json( | |
| { error: "Unauthorized. Send Authorization: Bearer <ENDPOINT_AUTH_TOKEN>." }, | |
| 401, | |
| { "www-authenticate": "Bearer" }, | |
| ); | |
| } | |
| const configErrors = validateConfig(CONFIG); | |
| if (configErrors.length > 0) { | |
| return json( | |
| { | |
| error: "Configuration is incomplete.", | |
| issues: configErrors, | |
| }, | |
| 500, | |
| ); | |
| } | |
| try { | |
| const token = await getRequestToken(CONFIG); | |
| const accountInfo = await getAccountInformation(token, CONFIG); | |
| const [startUtc, endUtc] = getTodayUtcRange(CONFIG.timeZone); | |
| const telemetry = await getEnergyTelemetry(token, accountInfo, startUtc, endUtc, CONFIG); | |
| const gas = calculateEnergyStatistics(telemetry.gasTelemetry, (reading) => { | |
| return toNumber(reading.costDeltaWithTax) ?? 0; | |
| }); | |
| const electricity = calculateEnergyStatistics(telemetry.elecTelemetry, (reading) => { | |
| const numberWatts = toNumber(reading.consumptionDelta); | |
| if (numberWatts == null) return 0; | |
| const unitCost = getUnitCostForReading(reading.readAt, accountInfo.electricityUnitCost); | |
| if (unitCost == null) { | |
| // Fallback when unit tariff is unavailable for a timestamp. | |
| return toNumber(reading.costDeltaWithTax) ?? 0; | |
| } | |
| return (numberWatts / 1000) * unitCost; | |
| }); | |
| return json( | |
| { | |
| startUtc, | |
| endUtc, | |
| gas, | |
| electricity, | |
| }, | |
| 200, | |
| { "cache-control": "no-store" }, | |
| ); | |
| } catch (error) { | |
| const message = error instanceof Error ? error.message : "Unknown error"; | |
| return json( | |
| { | |
| error: "Failed to load Octopus energy data.", | |
| message, | |
| }, | |
| 500, | |
| ); | |
| } | |
| } | |
| async function getRequestToken(config: AppConfig): Promise<string> { | |
| const data = await graphqlRequest<TokenQueryData>({ | |
| config, | |
| query: OBTAIN_KRAKEN_TOKEN_MUTATION, | |
| variables: { | |
| input: { | |
| APIKey: config.octopusApiKey, | |
| }, | |
| }, | |
| }); | |
| const token = data.obtainKrakenToken?.token; | |
| if (!token) { | |
| throw new Error("Octopus auth response did not include a token."); | |
| } | |
| return token; | |
| } | |
| async function getAccountInformation( | |
| token: string, | |
| config: AppConfig, | |
| ): Promise<{ | |
| electricityDeviceId: string; | |
| gasDeviceId: string; | |
| electricityUnitCost: UnitCostModel; | |
| }> { | |
| const data = await graphqlRequest<AccountQueryData>({ | |
| config, | |
| token, | |
| query: ACCOUNT_INFORMATION_QUERY, | |
| variables: { | |
| accountNumber: config.octopusAccountNumber, | |
| }, | |
| }); | |
| const electricityAgreements = data.account?.electricityAgreements ?? []; | |
| const gasAgreements = data.account?.gasAgreements ?? []; | |
| const electricityDeviceId = findFirstDeviceId(electricityAgreements, "electricity"); | |
| const gasDeviceId = findFirstDeviceId(gasAgreements, "gas"); | |
| const electricityUnitCost = findElectricityUnitCostModel(electricityAgreements); | |
| return { | |
| electricityDeviceId, | |
| gasDeviceId, | |
| electricityUnitCost, | |
| }; | |
| } | |
| async function getEnergyTelemetry( | |
| token: string, | |
| accountInfo: { electricityDeviceId: string; gasDeviceId: string }, | |
| startUtc: string, | |
| endUtc: string, | |
| config: AppConfig, | |
| ): Promise<{ elecTelemetry: TelemetryReading[]; gasTelemetry: TelemetryReading[] }> { | |
| const data = await graphqlRequest<TelemetryQueryData>({ | |
| config, | |
| token, | |
| query: TELEMETRY_QUERY, | |
| variables: { | |
| elecDeviceId: accountInfo.electricityDeviceId, | |
| gasDeviceId: accountInfo.gasDeviceId, | |
| startUtc, | |
| endUtc, | |
| }, | |
| }); | |
| return { | |
| elecTelemetry: data.elecTelemetry ?? [], | |
| gasTelemetry: data.gasTelemetry ?? [], | |
| }; | |
| } | |
| function calculateEnergyStatistics( | |
| telemetry: TelemetryReading[], | |
| getEnergyReadingCost: (reading: TelemetryReading) => number, | |
| ): EnergyStatistic { | |
| let totalWatts = 0; | |
| let totalCost = 0; | |
| for (const reading of telemetry) { | |
| const numberWatts = toNumber(reading.consumptionDelta); | |
| if (numberWatts != null) { | |
| totalWatts += numberWatts; | |
| } | |
| totalCost += getEnergyReadingCost(reading); | |
| } | |
| return { | |
| totalWatts, | |
| totalCost, | |
| }; | |
| } | |
| function getUnitCostForReading(readAtIso: string, unitCost: UnitCostModel): number | null { | |
| if (typeof unitCost === "number") { | |
| return unitCost; | |
| } | |
| const readAt = Date.parse(readAtIso); | |
| if (Number.isNaN(readAt)) { | |
| return null; | |
| } | |
| for (const rate of unitCost) { | |
| const from = Date.parse(rate.validFrom); | |
| const to = rate.validTo ? Date.parse(rate.validTo) : Number.POSITIVE_INFINITY; | |
| if (Number.isNaN(from) || Number.isNaN(to)) { | |
| continue; | |
| } | |
| if (readAt >= from && readAt < to) { | |
| const parsedRate = toNumber(rate.value); | |
| if (parsedRate != null) return parsedRate; | |
| } | |
| } | |
| return null; | |
| } | |
| function findElectricityUnitCostModel( | |
| agreements: Array<{ tariff?: { unitRate?: RawNumber; dayRate?: RawNumber; nightRate?: RawNumber; unitRates?: UnitRateWindow[] | null } | null }>, | |
| ): UnitCostModel { | |
| for (const agreement of agreements) { | |
| const tariff = agreement.tariff; | |
| if (!tariff) continue; | |
| const halfHourly = (tariff.unitRates ?? []).filter( | |
| (rate) => toNumber(rate.value) != null && Boolean(rate.validFrom), | |
| ); | |
| if (halfHourly.length > 0) { | |
| return halfHourly; | |
| } | |
| const flatRate = | |
| toNumber(tariff.unitRate) ?? | |
| toNumber(tariff.dayRate) ?? | |
| toNumber(tariff.nightRate); | |
| if (flatRate != null) { | |
| return flatRate; | |
| } | |
| } | |
| throw new Error("No electricity tariff rate found in active agreements."); | |
| } | |
| function findFirstDeviceId( | |
| agreements: Array<{ meterPoint?: { meters?: Array<{ smartDevices?: Array<{ deviceId?: string | null }> | null }> | null } | null }>, | |
| label: "electricity" | "gas", | |
| ): string { | |
| for (const agreement of agreements) { | |
| for (const meter of agreement.meterPoint?.meters ?? []) { | |
| for (const device of meter.smartDevices ?? []) { | |
| if (device.deviceId) { | |
| return device.deviceId; | |
| } | |
| } | |
| } | |
| } | |
| throw new Error(`No ${label} smart meter device ID found in active agreements.`); | |
| } | |
| function getTodayUtcRange(timeZone: string): [string, string] { | |
| const today = getDatePartsInTimeZone(new Date(), timeZone); | |
| const start = zonedDateTimeToUtcIso( | |
| { | |
| year: today.year, | |
| month: today.month, | |
| day: today.day, | |
| hour: 0, | |
| minute: 0, | |
| second: 0, | |
| }, | |
| timeZone, | |
| ); | |
| const end = zonedDateTimeToUtcIso( | |
| { | |
| year: today.year, | |
| month: today.month, | |
| day: today.day, | |
| hour: 23, | |
| minute: 59, | |
| second: 59, | |
| }, | |
| timeZone, | |
| ); | |
| return [start, end]; | |
| } | |
| function getDatePartsInTimeZone(date: Date, timeZone: string): DateTimeParts { | |
| const formatter = new Intl.DateTimeFormat("en-GB", { | |
| timeZone, | |
| hour12: false, | |
| year: "numeric", | |
| month: "2-digit", | |
| day: "2-digit", | |
| hour: "2-digit", | |
| minute: "2-digit", | |
| second: "2-digit", | |
| }); | |
| const parts = formatter.formatToParts(date); | |
| const values: Partial<DateTimeParts> = {}; | |
| for (const part of parts) { | |
| if ( | |
| part.type === "year" || | |
| part.type === "month" || | |
| part.type === "day" || | |
| part.type === "hour" || | |
| part.type === "minute" || | |
| part.type === "second" | |
| ) { | |
| values[part.type] = Number(part.value); | |
| } | |
| } | |
| if ( | |
| values.year == null || | |
| values.month == null || | |
| values.day == null || | |
| values.hour == null || | |
| values.minute == null || | |
| values.second == null | |
| ) { | |
| throw new Error(`Failed to compute date parts for timezone "${timeZone}".`); | |
| } | |
| return values as DateTimeParts; | |
| } | |
| function zonedDateTimeToUtcIso(parts: DateTimeParts, timeZone: string): string { | |
| // Convert local wall-clock time in a timezone into a UTC timestamp, with a second | |
| // pass to correctly handle timezone offset transitions (DST boundaries). | |
| let utcGuess = Date.UTC( | |
| parts.year, | |
| parts.month - 1, | |
| parts.day, | |
| parts.hour, | |
| parts.minute, | |
| parts.second, | |
| ); | |
| const firstOffset = getTimeZoneOffsetMs(new Date(utcGuess), timeZone); | |
| utcGuess -= firstOffset; | |
| const secondOffset = getTimeZoneOffsetMs(new Date(utcGuess), timeZone); | |
| if (firstOffset !== secondOffset) { | |
| utcGuess -= secondOffset - firstOffset; | |
| } | |
| return new Date(utcGuess).toISOString(); | |
| } | |
| function getTimeZoneOffsetMs(date: Date, timeZone: string): number { | |
| const tzParts = getDatePartsInTimeZone(date, timeZone); | |
| const asUtc = Date.UTC( | |
| tzParts.year, | |
| tzParts.month - 1, | |
| tzParts.day, | |
| tzParts.hour, | |
| tzParts.minute, | |
| tzParts.second, | |
| ); | |
| return asUtc - date.getTime(); | |
| } | |
| async function graphqlRequest<TData>(args: { | |
| config: AppConfig; | |
| query: string; | |
| variables?: Record<string, unknown>; | |
| token?: string; | |
| }): Promise<TData> { | |
| const response = await fetch(args.config.octopusGraphqlUrl, { | |
| method: "POST", | |
| headers: { | |
| "content-type": "application/json", | |
| ...(args.token ? { authorization: `Bearer ${args.token}` } : {}), | |
| }, | |
| body: JSON.stringify({ | |
| query: args.query, | |
| variables: args.variables, | |
| }), | |
| }); | |
| if (!response.ok) { | |
| const errorBody = await response.text(); | |
| throw new Error(`Octopus GraphQL request failed (${response.status}): ${errorBody}`); | |
| } | |
| const payload = (await response.json()) as GraphQLResult<TData>; | |
| if (payload.errors && payload.errors.length > 0) { | |
| const message = payload.errors.map((error) => error.message).join("; "); | |
| throw new Error(`Octopus GraphQL returned errors: ${message}`); | |
| } | |
| if (!payload.data) { | |
| throw new Error("Octopus GraphQL response did not contain data."); | |
| } | |
| return payload.data; | |
| } | |
| function toNumber(value: RawNumber): number | null { | |
| if (typeof value === "number") { | |
| return Number.isFinite(value) ? value : null; | |
| } | |
| if (typeof value === "string" && value.trim().length > 0) { | |
| const parsed = Number(value); | |
| return Number.isFinite(parsed) ? parsed : null; | |
| } | |
| return null; | |
| } | |
| function isAuthorized(request: Request, token: string): boolean { | |
| const header = request.headers.get("authorization"); | |
| return header === `Bearer ${token}`; | |
| } | |
| function validateConfig(config: AppConfig): string[] { | |
| const issues: string[] = []; | |
| if (!config.octopusApiKey || config.octopusApiKey.startsWith("<")) { | |
| issues.push("Set OCTOPUS_API_KEY."); | |
| } | |
| if (!config.octopusAccountNumber || config.octopusAccountNumber.startsWith("<")) { | |
| issues.push("Set OCTOPUS_ACCOUNT_NUMBER."); | |
| } | |
| if (!config.endpointAuthToken || config.endpointAuthToken.startsWith("<")) { | |
| issues.push("Set ENDPOINT_AUTH_TOKEN."); | |
| } | |
| return issues; | |
| } | |
| function getEnv(key: string): string | undefined { | |
| const deno = (globalThis as { Deno?: { env?: { get: (name: string) => string | undefined } } }).Deno; | |
| if (deno?.env?.get) { | |
| return deno.env.get(key); | |
| } | |
| const processRef = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process; | |
| return processRef?.env?.[key]; | |
| } | |
| function json(body: unknown, status = 200, headers: Record<string, string> = {}): Response { | |
| return new Response(JSON.stringify(body, null, 2), { | |
| status, | |
| headers: { | |
| "content-type": "application/json; charset=utf-8", | |
| ...headers, | |
| }, | |
| }); | |
| } | |
| export default { | |
| fetch: handleRequest, | |
| }; | |
| // For Deno Deploy, uncomment: | |
| // Deno.serve(handleRequest); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment