Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 0 additions & 1 deletion .gitattributes

This file was deleted.

36 changes: 0 additions & 36 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,42 +32,6 @@ jobs:
- run: uv run pydocstyle --ignore=D211,D403 sparkmeter
continue-on-error: true

metering-wire-drift:
# Verify the committed sparkmeter/metering/_generated/ matches what
# the regen script would produce against the current spec. Catches
# cases where the spec changed but the webapp client wasn't
# regenerated, or where someone hand-edited generated files.
#
# TODO: parameterize the spec source. For now the job expects
# `OPENAPI_URL` to be set (a CI secret / repo variable pointing at
# the canonical openapi.json — release artifact, GitHub Pages, etc.)
# If unset, the drift check is skipped so CI doesn't block on this.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Skip if spec source not configured
id: have_spec
run: |
if [ -z "${{ vars.OPENAPI_URL }}" ]; then
echo "OPENAPI_URL not set; skipping drift check"
echo "skip=true" >> "$GITHUB_OUTPUT"
fi
- if: steps.have_spec.outputs.skip != 'true'
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- if: steps.have_spec.outputs.skip != 'true'
run: uv python install 3.13
- if: steps.have_spec.outputs.skip != 'true'
name: Fetch openapi.json
run: curl -fsSL "${{ vars.OPENAPI_URL }}" -o /tmp/openapi.json
- if: steps.have_spec.outputs.skip != 'true'
name: Regenerate metering client
env:
OPENAPI_PATH: /tmp/openapi.json
run: ./scripts/regen-metering-wire.sh
- if: steps.have_spec.outputs.skip != 'true'
name: Verify no drift in committed _generated/
run: git diff --exit-code sparkmeter/metering/_generated/

test:
runs-on: ubuntu-latest
permissions:
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
.noseids
.DS_Store

# local meter driver config fixture
meter_driver_configs/*.json

# per deployment settings
instance/*

Expand Down
8 changes: 4 additions & 4 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ repos:
rev: v6.0.0
hooks:
- id: trailing-whitespace
exclude: ^(test-data/|sparkmeter/metering/_generated/)
exclude: ^test-data/
- id: end-of-file-fixer
exclude: ^(test-data/|sparkmeter/metering/_generated/)
exclude: ^test-data/
- id: check-added-large-files

- repo: https://github.com/gitleaks/gitleaks
Expand All @@ -20,9 +20,9 @@ repos:
hooks:
- id: ruff-check
args: ["--fix"]
exclude: ^(sparkmeter/metering/_generated/|test-data/)
exclude: ^test-data/
- id: ruff-format
exclude: ^(sparkmeter/metering/_generated/|test-data/)
exclude: ^test-data/

- repo: local
hooks:
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,20 @@ For Docker deployments, run the same commands inside the webapp container with `
docker compose exec ground uv run flask user create
```

## Meter drivers

Thundercloud is not tied to any one meter driver. It talks to a driver over the HTTP+SSE
contract (and optionally gRPC) from the Thunder-Cloud 2.0 Open Source Meter Driver
Specification, so any compliant driver works — SparkNet-Http or a third party's.

Run the driver as its own service, then register it from the running ground app under
**Global Settings > Meter Drivers > Register driver** by entering the base URL of its HTTP
service. Registered drivers become selectable per meter on the meter form.

The groundbolt-dev workspace metarepo runs `sparknet-http` as part of its stack for
convenience during development; that is a choice of that stack, not a dependency of this
application.

## Development

This document is to help you get the development environment up and running. You can choose between two options, hit either of the links below for more details:
Expand Down
49 changes: 49 additions & 0 deletions assets/stylesheets/sparkmeter.scss
Original file line number Diff line number Diff line change
Expand Up @@ -308,3 +308,52 @@ dd.tags {
#override-modal a {
color: #ffffff;
}

/* Chromium/WebView fallback for old Font Awesome 3 glyphs.
* Some local dev environments render the legacy font as empty squares
* even though the files are present. Re-map the most common TC icons
* to Bootstrap glyphicons, which are already shipped in this repo.
*/
.icon-home:before,
.icon-user:before,
.icon-th:before,
.icon-book:before,
.icon-bar-chart:before,
.icon-dashboard:before,
.icon-table:before,
.icon-money:before,
.icon-credit-card:before,
.icon-group:before,
.icon-key:before,
.icon-signin:before,
.icon-comment:before,
.icon-download:before,
.icon-download-alt:before,
.icon-cog:before,
.icon-edit:before,
.icon-plus:before,
.icon-align-justify:before,
.icon-caret-down:before {
font-family: 'Glyphicons Halflings';
}

.icon-home:before { content: "\e021"; }
.icon-user:before { content: "\e008"; }
.icon-th:before { content: "\e011"; }
.icon-book:before { content: "\e043"; }
.icon-bar-chart:before { content: "\e185"; }
.icon-dashboard:before { content: "\e141"; }
.icon-table:before { content: "\e185"; }
.icon-money:before { content: "\e148"; }
.icon-credit-card:before { content: "\e177"; }
.icon-group:before { content: "\e008"; }
.icon-key:before { content: "\e033"; }
.icon-signin:before { content: "\e161"; }
.icon-comment:before { content: "\e111"; }
.icon-download:before { content: "\e026"; }
.icon-download-alt:before { content: "\e025"; }
.icon-cog:before { content: "\e019"; }
.icon-edit:before { content: "\e065"; }
.icon-plus:before { content: "\2b"; }
.icon-align-justify:before { content: "\e056"; }
.icon-caret-down:before { content: "\e114"; }
15 changes: 11 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ dependencies = [
"requests",
"httpx",

# Used by the generated metering-provider client at
# sparkmeter/metering/_generated/ (pyopenapi-gen output).
# Structures raw metering-provider SSE event dicts into the local
# dataclasses in sparkmeter/metering/legacy_event_models.py.
"cattrs",

# AWS S3 — used by the historical-data feature in sparkmeter/history/.
Expand All @@ -54,7 +54,12 @@ dependencies = [
"fastapi",
"hypercorn",
"a2wsgi",
# gRPC contract for the optional meter-driver profile. The generated
# stubs come from the meter-driver-spec package (pinned to a spec tag),
# not from local codegen; protobuf/grpcio are their runtime.
"meter-driver-spec @ https://github.com/EarthSpark/meter-driver-spec/releases/download/v1.4.0/meter_driver_spec-1.4.0-py3-none-any.whl",
"protobuf",
"grpcio",
"click",

# dates and times
Expand All @@ -73,6 +78,9 @@ dependencies = [
"speaklater",
]

[tool.hatch.metadata]
allow-direct-references = true

[tool.hatch.version]
source = "vcs"
# uv's build isolation invokes hatch-vcs from a temp directory that
Expand Down Expand Up @@ -104,15 +112,14 @@ dev = [
"ruff>=0.15.21",
"pydocstyle",
"pre-commit",
"grpcio-tools",
"pyopenapi-gen",
"pytest-diagnose @ git+https://github.com/tescalada/pytest-diagnose.git",
"pytest-randomly>=4.1.0",
]

[tool.ruff]
line-length = 110
extend-exclude = ["sparkmeter/metering/_generated", "test-data"]
extend-exclude = ["test-data"]

[tool.ruff.lint]
select = ["F", "I"]
Expand Down
34 changes: 0 additions & 34 deletions scripts/regen-metering-wire.sh

This file was deleted.

4 changes: 0 additions & 4 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ norecursedirs =
.eggs .git build venv venv-3.6
scripts/config/node_modules dist deploy/build
node_docker venv_docker
sparkmeter/metering/_generated
python_classes = *Test Test*

[coverage:xml]
Expand All @@ -25,10 +24,7 @@ relative_files = True
omit =
**/test_*.py
sparkmeter/tests/base.py
sparkmeter/metering/_generated/**

[flake8]
select = E,W,H
max-line-length = 110
exclude =
sparkmeter/metering/_generated
26 changes: 26 additions & 0 deletions sparkmeter/alembic/versions/0.84_add_meter_driver_selection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Copyright (C) 2013-2026 SparkMeter, Inc.
# All Rights Reserved.
"""add meter driver selection.

Revision ID: 0.84
Revises: 0.83
Create Date: 2026-07-11

"""

import sqlalchemy as sa
from alembic import op

revision = "0.84"
down_revision = "0.83"


def upgrade():
"""Upgrade the database schema from 0.83 to 0.84."""
op.add_column("meter", sa.Column("provider_id", sa.String(), nullable=True))


def downgrade(): # pragma: nocoverage
"""Downgrade the database schema from 0.84 to 0.83."""
op.execute("DROP VIEW IF EXISTS meter_view")
op.drop_column("meter", "provider_id")
37 changes: 29 additions & 8 deletions sparkmeter/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,18 @@
from sparkmeter.app import SparkmeterApplication # noqa: E402
from sparkmeter.cli import register_cli_commands # noqa: E402
from sparkmeter.metering.lifespan import metering_lifespan # noqa: E402
from sparkmeter.metering.runtime_registry import ( # noqa: E402
get_running_app,
set_running_app,
)
from sparkmeter.periodic import periodic_lifespan # noqa: E402

# When this file is launched via `python -m sparkmeter.asgi`, Python executes it
# as `__main__`. Register the canonical module name as an alias to the running
# module so in-process helpers don't accidentally import a second copy.
if __name__ == "__main__":
sys.modules.setdefault("sparkmeter.asgi", sys.modules[__name__])

Check failure on line 45 in sparkmeter/asgi.py

View workflow job for this annotation

GitHub Actions / test

Missing Coverage

Line 45 missing coverage


@asynccontextmanager
async def app_lifespan(app: FastAPI):
Expand All @@ -42,6 +52,7 @@
metering_lifespan is wrapped on the inside so its teardown (drain
SSE / dispatch) runs before periodic jobs are cancelled.
"""
app.state.main_loop = asyncio.get_running_loop()
async with periodic_lifespan(app):
async with metering_lifespan(app):
yield
Expand Down Expand Up @@ -81,6 +92,9 @@
# which is bound to a per-request thread-local that lifespan threads
# never enter.
api.state.flask_app = flask_app
# Publish to the registry so the sync→async metering bridge can reach the
# running app without reflecting over sys.modules.
set_running_app(api)
# Mount Flask under "/" — FastAPI's own routes take precedence; everything
# else falls through to the WSGI app.
api.mount("/", WSGIMiddleware(flask_app))
Expand Down Expand Up @@ -109,19 +123,26 @@
# a live DB connection). ASGI servers reference these by attribute, which fires
# the factory at first access. Tests / introspection that just want to import
# the module don't pay that cost.
#
# The public app's single cache is the registry (`get_running_app`); the
# internal app has no cross-module consumer, so it caches here in a private
# module-level variable. Neither writes a string-keyed module global.
_internal_app = None


def _ensure_public_app():
app = get_running_app()
return app if app is not None else create_public_app()


def __getattr__(name): # PEP 562 module-level __getattr__
global _internal_app
if name == "public_app":
app = create_public_app()
globals()["public_app"] = app
return app
return _ensure_public_app()
if name == "internal_app":
public = globals().get("public_app") or create_public_app()
globals()["public_app"] = public
app = create_internal_app(public)
globals()["internal_app"] = app
return app
if _internal_app is None:
_internal_app = create_internal_app(_ensure_public_app())
return _internal_app
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")


Expand Down
2 changes: 0 additions & 2 deletions sparkmeter/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ def register_cli_commands(app):
from sparkmeter.ground.groundcommand import create_ground
from sparkmeter.meter.metercommand import meter
from sparkmeter.metering.cli import metering
from sparkmeter.reading.readingcommand import reading
from sparkmeter.salesaccount.salesaccountcommand import salesaccount
from sparkmeter.servercommand import server, shell
from sparkmeter.system.systemcommand import status, system
Expand All @@ -34,7 +33,6 @@ def register_cli_commands(app):
app.cli.add_command(event)
app.cli.add_command(meter)
app.cli.add_command(metering)
app.cli.add_command(reading)
app.cli.add_command(salesaccount)
app.cli.add_command(server)
app.cli.add_command(system)
Expand Down
4 changes: 4 additions & 0 deletions sparkmeter/config/configdict.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,5 +151,9 @@ def is_cloud(self):
"""Get if this a cloud system."""
return self.local_system == self.CLOUD

def is_offline(self):
"""Get if metering-provider startup is disabled for this process."""
return bool(self.get("OFFLINE", False))


config = ConfigDict()
Loading
Loading