Compare commits
No commits in common. "337ee06cafc801412439fce654d638421704d531" and "c55f089247359ce07d5128232d590cb255de5887" have entirely different histories.
337ee06caf
...
c55f089247
@ -1,100 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"flag"
|
|
||||||
"log"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
"weatherstation/internal/database"
|
|
||||||
"weatherstation/internal/server"
|
|
||||||
"weatherstation/internal/tools"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
// 命令行参数
|
|
||||||
var webOnly = flag.Bool("web", false, "只启动Web服务器(Gin)")
|
|
||||||
var udpOnly = flag.Bool("udp", false, "只启动UDP服务器")
|
|
||||||
// 调试:回填10分钟表
|
|
||||||
var doBackfill = flag.Bool("backfill", false, "将16s原始数据聚合写入10分钟表(调试用途)")
|
|
||||||
var bfStation = flag.String("station", "", "指定站点ID(为空则全站回填)")
|
|
||||||
var bfFrom = flag.String("from", "", "回填起始时间,格式:YYYY-MM-DD HH:MM:SS")
|
|
||||||
var bfTo = flag.String("to", "", "回填结束时间,格式:YYYY-MM-DD HH:MM:SS")
|
|
||||||
var bfWrap = flag.Float64("wrap", 0, "回绕一圈对应毫米值(mm),<=0 则降级为仅计当前值")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
// 设置日志
|
|
||||||
server.SetupLogger()
|
|
||||||
|
|
||||||
// 初始化数据库连接
|
|
||||||
_ = database.GetDB() // 确保数据库连接已初始化
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
// Backfill 调试路径
|
|
||||||
if *doBackfill {
|
|
||||||
if *bfFrom == "" || *bfTo == "" {
|
|
||||||
log.Fatalln("backfill 需要提供 --from 与 --to 时间")
|
|
||||||
}
|
|
||||||
fromT, err := time.Parse("2006-01-02 15:04:05", *bfFrom)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("解析from失败: %v", err)
|
|
||||||
}
|
|
||||||
toT, err := time.Parse("2006-01-02 15:04:05", *bfTo)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("解析to失败: %v", err)
|
|
||||||
}
|
|
||||||
ctx := context.Background()
|
|
||||||
if err := tools.RunBackfill10Min(ctx, tools.BackfillOptions{
|
|
||||||
StationID: *bfStation,
|
|
||||||
FromTime: fromT,
|
|
||||||
ToTime: toT,
|
|
||||||
WrapCycleMM: *bfWrap,
|
|
||||||
BucketMinutes: 10,
|
|
||||||
}); err != nil {
|
|
||||||
log.Fatalf("回填失败: %v", err)
|
|
||||||
}
|
|
||||||
log.Println("回填完成")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据命令行参数启动服务
|
|
||||||
if *webOnly {
|
|
||||||
// 只启动Web服务器
|
|
||||||
log.Println("启动Web服务器模式...")
|
|
||||||
if err := server.StartGinServer(); err != nil {
|
|
||||||
log.Fatalf("启动Web服务器失败: %v", err)
|
|
||||||
}
|
|
||||||
} else if *udpOnly {
|
|
||||||
// 只启动UDP服务器
|
|
||||||
log.Println("启动UDP服务器模式...")
|
|
||||||
if err := server.StartUDPServer(); err != nil {
|
|
||||||
log.Fatalf("启动UDP服务器失败: %v", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 同时启动UDP和Web服务器
|
|
||||||
log.Println("启动完整模式:UDP + Web服务器...")
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
wg.Add(2)
|
|
||||||
|
|
||||||
// 启动UDP服务器
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
log.Println("正在启动UDP服务器...")
|
|
||||||
if err := server.StartUDPServer(); err != nil {
|
|
||||||
log.Printf("UDP服务器异常退出: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// 启动Web服务器
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
log.Println("正在启动Web服务器...")
|
|
||||||
if err := server.StartGinServer(); err != nil {
|
|
||||||
log.Printf("Web服务器异常退出: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
539
db/schema.sql
539
db/schema.sql
@ -1,539 +0,0 @@
|
|||||||
--
|
|
||||||
-- PostgreSQL database dump
|
|
||||||
--
|
|
||||||
|
|
||||||
-- Dumped from database version 14.18 (Ubuntu 14.18-0ubuntu0.22.04.1)
|
|
||||||
-- Dumped by pg_dump version 17.5
|
|
||||||
|
|
||||||
SET statement_timeout = 0;
|
|
||||||
SET lock_timeout = 0;
|
|
||||||
SET idle_in_transaction_session_timeout = 0;
|
|
||||||
SET transaction_timeout = 0;
|
|
||||||
SET client_encoding = 'UTF8';
|
|
||||||
SET standard_conforming_strings = on;
|
|
||||||
SELECT pg_catalog.set_config('search_path', '', false);
|
|
||||||
SET check_function_bodies = false;
|
|
||||||
SET xmloption = content;
|
|
||||||
SET client_min_messages = warning;
|
|
||||||
SET row_security = off;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: public; Type: SCHEMA; Schema: -; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
-- *not* creating schema, since initdb creates it
|
|
||||||
|
|
||||||
|
|
||||||
SET default_tablespace = '';
|
|
||||||
|
|
||||||
SET default_table_access_method = heap;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: rs485_weather_data; Type: TABLE; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE public.rs485_weather_data (
|
|
||||||
id integer NOT NULL,
|
|
||||||
station_id character varying(50) NOT NULL,
|
|
||||||
"timestamp" timestamp with time zone NOT NULL,
|
|
||||||
temperature double precision,
|
|
||||||
humidity double precision,
|
|
||||||
wind_speed double precision,
|
|
||||||
wind_direction double precision,
|
|
||||||
rainfall double precision,
|
|
||||||
light double precision,
|
|
||||||
uv double precision,
|
|
||||||
pressure double precision,
|
|
||||||
raw_data text
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: rs485_weather_data_bak; Type: TABLE; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE public.rs485_weather_data_bak (
|
|
||||||
id integer,
|
|
||||||
station_id character varying(50),
|
|
||||||
"timestamp" timestamp without time zone,
|
|
||||||
temperature numeric(5,2),
|
|
||||||
humidity numeric(5,2),
|
|
||||||
wind_speed numeric(5,2),
|
|
||||||
wind_direction numeric(5,2),
|
|
||||||
rainfall numeric(5,2),
|
|
||||||
light numeric(15,2),
|
|
||||||
uv numeric(8,2),
|
|
||||||
pressure numeric(7,2),
|
|
||||||
raw_data text
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: rs485_weather_data_id_seq; Type: SEQUENCE; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SEQUENCE public.rs485_weather_data_id_seq
|
|
||||||
AS integer
|
|
||||||
START WITH 1
|
|
||||||
INCREMENT BY 1
|
|
||||||
NO MINVALUE
|
|
||||||
NO MAXVALUE
|
|
||||||
CACHE 1;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: rs485_weather_data_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER SEQUENCE public.rs485_weather_data_id_seq OWNED BY public.rs485_weather_data.id;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: stations; Type: TABLE; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE public.stations (
|
|
||||||
station_id character varying(50) NOT NULL,
|
|
||||||
password character varying(50) NOT NULL,
|
|
||||||
name character varying(100),
|
|
||||||
location character varying(100),
|
|
||||||
latitude numeric(10,6),
|
|
||||||
longitude numeric(10,6),
|
|
||||||
altitude numeric(8,3),
|
|
||||||
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
last_update timestamp with time zone,
|
|
||||||
software_type character varying(100),
|
|
||||||
device_type character varying(20) DEFAULT 'UNKNOWN'::character varying NOT NULL,
|
|
||||||
CONSTRAINT check_device_type CHECK (((device_type)::text = ANY ((ARRAY['ECOWITT'::character varying, 'WH65LP'::character varying, 'UNKNOWN'::character varying])::text[])))
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: TABLE stations; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON TABLE public.stations IS '气象站设备信息表,存储设备的基本信息和认证信息';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: COLUMN stations.device_type; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON COLUMN public.stations.device_type IS 'ECOWITT: WIFI型, WH65LP: 485型';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: weather_data; Type: TABLE; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE public.weather_data (
|
|
||||||
id integer NOT NULL,
|
|
||||||
station_id character varying(50) NOT NULL,
|
|
||||||
"timestamp" timestamp with time zone NOT NULL,
|
|
||||||
temp_f integer,
|
|
||||||
humidity integer,
|
|
||||||
dewpoint_f integer,
|
|
||||||
windchill_f integer,
|
|
||||||
wind_dir integer,
|
|
||||||
wind_speed_mph integer,
|
|
||||||
wind_gust_mph integer,
|
|
||||||
rain_in integer,
|
|
||||||
daily_rain_in integer,
|
|
||||||
weekly_rain_in integer,
|
|
||||||
monthly_rain_in integer,
|
|
||||||
yearly_rain_in integer,
|
|
||||||
total_rain_in integer,
|
|
||||||
solar_radiation integer,
|
|
||||||
uv integer,
|
|
||||||
indoor_temp_f integer,
|
|
||||||
indoor_humidity integer,
|
|
||||||
abs_barometer_in integer,
|
|
||||||
barometer_in integer,
|
|
||||||
low_battery boolean,
|
|
||||||
raw_data text
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: TABLE weather_data; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON TABLE public.weather_data IS '气象站数据表,存储所有气象观测数据,数值型数据以整数形式存储,查询时需进行转换';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: COLUMN weather_data.id; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON COLUMN public.weather_data.id IS '自增主键ID';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: COLUMN weather_data.station_id; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON COLUMN public.weather_data.station_id IS '气象站ID,外键关联stations表';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: COLUMN weather_data."timestamp"; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON COLUMN public.weather_data."timestamp" IS '数据记录时间,使用UTC+8时区(中国标准时间)';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: COLUMN weather_data.temp_f; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON COLUMN public.weather_data.temp_f IS '室外温度,存储值=实际值×10,单位:华氏度,查询时需除以10,如768表示76.8°F';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: COLUMN weather_data.humidity; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON COLUMN public.weather_data.humidity IS '室外湿度,单位:百分比,如53表示53%';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: COLUMN weather_data.dewpoint_f; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON COLUMN public.weather_data.dewpoint_f IS '露点温度,存储值=实际值×10,单位:华氏度,查询时需除以10,如585表示58.5°F';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: COLUMN weather_data.windchill_f; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON COLUMN public.weather_data.windchill_f IS '风寒指数,存储值=实际值×10,单位:华氏度,查询时需除以10,如768表示76.8°F';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: COLUMN weather_data.wind_dir; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON COLUMN public.weather_data.wind_dir IS '风向,单位:角度(0-359),如44表示东北风(44°)';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: COLUMN weather_data.wind_speed_mph; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON COLUMN public.weather_data.wind_speed_mph IS '风速,存储值=实际值×100,单位:英里/小时,查询时需除以100,如100表示1.00mph';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: COLUMN weather_data.wind_gust_mph; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON COLUMN public.weather_data.wind_gust_mph IS '阵风速度,存储值=实际值×100,单位:英里/小时,查询时需除以100,如100表示1.00mph';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: COLUMN weather_data.rain_in; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON COLUMN public.weather_data.rain_in IS '当前降雨速率,存储值=实际值×1000,单位:英寸/小时,查询时需除以1000,如500表示0.500英寸/小时';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: COLUMN weather_data.daily_rain_in; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON COLUMN public.weather_data.daily_rain_in IS '日降雨量,存储值=实际值×1000,单位:英寸,查询时需除以1000,如500表示0.500英寸';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: COLUMN weather_data.weekly_rain_in; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON COLUMN public.weather_data.weekly_rain_in IS '周降雨量,存储值=实际值×1000,单位:英寸,查询时需除以1000,如500表示0.500英寸';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: COLUMN weather_data.monthly_rain_in; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON COLUMN public.weather_data.monthly_rain_in IS '月降雨量,存储值=实际值×1000,单位:英寸,查询时需除以1000,如79表示0.079英寸';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: COLUMN weather_data.yearly_rain_in; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON COLUMN public.weather_data.yearly_rain_in IS '年降雨量,存储值=实际值×1000,单位:英寸,查询时需除以1000,如79表示0.079英寸';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: COLUMN weather_data.total_rain_in; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON COLUMN public.weather_data.total_rain_in IS '总降雨量,存储值=实际值×1000,单位:英寸,查询时需除以1000,如79表示0.079英寸';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: COLUMN weather_data.solar_radiation; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON COLUMN public.weather_data.solar_radiation IS '太阳辐射,存储值=实际值×100,单位:W/m²,查询时需除以100,如172表示1.72W/m²';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: COLUMN weather_data.uv; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON COLUMN public.weather_data.uv IS '紫外线指数,整数值,如0表示无紫外线';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: COLUMN weather_data.indoor_temp_f; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON COLUMN public.weather_data.indoor_temp_f IS '室内温度,存储值=实际值×10,单位:华氏度,查询时需除以10,如837表示83.7°F';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: COLUMN weather_data.indoor_humidity; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON COLUMN public.weather_data.indoor_humidity IS '室内湿度,单位:百分比,如48表示48%';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: COLUMN weather_data.abs_barometer_in; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON COLUMN public.weather_data.abs_barometer_in IS '绝对气压,存储值=实际值×1000,单位:英寸汞柱,查询时需除以1000,如29320表示29.320英寸汞柱';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: COLUMN weather_data.barometer_in; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON COLUMN public.weather_data.barometer_in IS '相对气压,存储值=实际值×1000,单位:英寸汞柱,查询时需除以1000,如29805表示29.805英寸汞柱';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: COLUMN weather_data.low_battery; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON COLUMN public.weather_data.low_battery IS '低电量标志,布尔值,true表示电量低';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: COLUMN weather_data.raw_data; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON COLUMN public.weather_data.raw_data IS '原始数据字符串';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: weather_data_id_seq; Type: SEQUENCE; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SEQUENCE public.weather_data_id_seq
|
|
||||||
AS integer
|
|
||||||
START WITH 1
|
|
||||||
INCREMENT BY 1
|
|
||||||
NO MINVALUE
|
|
||||||
NO MAXVALUE
|
|
||||||
CACHE 1;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: weather_data_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER SEQUENCE public.weather_data_id_seq OWNED BY public.weather_data.id;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: rs485_weather_data id; Type: DEFAULT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.rs485_weather_data ALTER COLUMN id SET DEFAULT nextval('public.rs485_weather_data_id_seq'::regclass);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: weather_data id; Type: DEFAULT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.weather_data ALTER COLUMN id SET DEFAULT nextval('public.weather_data_id_seq'::regclass);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: rs485_weather_data rs485_udx; Type: CONSTRAINT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.rs485_weather_data
|
|
||||||
ADD CONSTRAINT rs485_udx UNIQUE (station_id, "timestamp");
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: rs485_weather_data rs485_weather_data_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.rs485_weather_data
|
|
||||||
ADD CONSTRAINT rs485_weather_data_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: stations stations_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.stations
|
|
||||||
ADD CONSTRAINT stations_pkey PRIMARY KEY (station_id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: weather_data weather_data_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.weather_data
|
|
||||||
ADD CONSTRAINT weather_data_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: idx_rwd_station_time; Type: INDEX; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE INDEX idx_rwd_station_time ON public.rs485_weather_data USING btree (station_id, "timestamp");
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: idx_rwd_time; Type: INDEX; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE INDEX idx_rwd_time ON public.rs485_weather_data USING btree ("timestamp");
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: idx_weather_data_station_timestamp; Type: INDEX; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE INDEX idx_weather_data_station_timestamp ON public.weather_data USING btree (station_id, "timestamp");
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: INDEX idx_weather_data_station_timestamp; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON INDEX public.idx_weather_data_station_timestamp IS '气象站ID和时间戳的复合索引';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: idx_weather_data_timestamp; Type: INDEX; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE INDEX idx_weather_data_timestamp ON public.weather_data USING btree ("timestamp");
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: INDEX idx_weather_data_timestamp; Type: COMMENT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
COMMENT ON INDEX public.idx_weather_data_timestamp IS '时间戳索引';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: rs485_weather_data rs485_weather_data_station_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.rs485_weather_data
|
|
||||||
ADD CONSTRAINT rs485_weather_data_station_id_fkey FOREIGN KEY (station_id) REFERENCES public.stations(station_id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: weather_data weather_data_station_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.weather_data
|
|
||||||
ADD CONSTRAINT weather_data_station_id_fkey FOREIGN KEY (station_id) REFERENCES public.stations(station_id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- PostgreSQL database dump complete
|
|
||||||
--
|
|
||||||
|
|
||||||
|
|
||||||
-- Name: rs485_weather_10min; Type: TABLE; Schema: public; Owner: -
|
|
||||||
-- 用途:10分钟粒度聚合(长期保留,缩放整数存储)
|
|
||||||
--
|
|
||||||
CREATE TABLE IF NOT EXISTS public.rs485_weather_10min (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
station_id character varying(50) NOT NULL,
|
|
||||||
"bucket_start" timestamp with time zone NOT NULL,
|
|
||||||
temp_c_x100 integer,
|
|
||||||
humidity_pct integer,
|
|
||||||
wind_speed_ms_x1000 integer,
|
|
||||||
wind_gust_ms_x1000 integer,
|
|
||||||
wind_dir_deg integer,
|
|
||||||
rain_10m_mm_x1000 integer,
|
|
||||||
rain_total_mm_x1000 integer,
|
|
||||||
solar_wm2_x100 integer,
|
|
||||||
uv_index integer,
|
|
||||||
pressure_hpa_x100 integer,
|
|
||||||
sample_count integer DEFAULT 0 NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 约束与索引
|
|
||||||
ALTER TABLE ONLY public.rs485_weather_10min
|
|
||||||
ADD CONSTRAINT r10_udx UNIQUE (station_id, "bucket_start");
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.rs485_weather_10min
|
|
||||||
ADD CONSTRAINT rs485_weather_10min_station_id_fkey FOREIGN KEY (station_id) REFERENCES public.stations(station_id);
|
|
||||||
|
|
||||||
CREATE INDEX idx_r10_station_time ON public.rs485_weather_10min USING btree (station_id, "bucket_start");
|
|
||||||
|
|
||||||
COMMENT ON TABLE public.rs485_weather_10min IS '10分钟聚合数据表,数值型以缩放整数存储(温度×100、风速×1000等)';
|
|
||||||
COMMENT ON COLUMN public.rs485_weather_10min."bucket_start" IS '10分钟桶开始时间(与CST对齐分桶,存储为timestamptz)';
|
|
||||||
COMMENT ON COLUMN public.rs485_weather_10min.temp_c_x100 IS '10分钟平均温度,单位℃×100';
|
|
||||||
COMMENT ON COLUMN public.rs485_weather_10min.humidity_pct IS '10分钟平均湿度,单位%';
|
|
||||||
COMMENT ON COLUMN public.rs485_weather_10min.wind_speed_ms_x1000 IS '10分钟平均风速,单位m/s×1000';
|
|
||||||
COMMENT ON COLUMN public.rs485_weather_10min.wind_gust_ms_x1000 IS '10分钟最大阵风,单位m/s×1000';
|
|
||||||
COMMENT ON COLUMN public.rs485_weather_10min.wind_dir_deg IS '10分钟风向向量平均,单位度(0-359)';
|
|
||||||
COMMENT ON COLUMN public.rs485_weather_10min.rain_10m_mm_x1000 IS '10分钟降雨量,按“带回绕正增量”计算,单位mm×1000';
|
|
||||||
COMMENT ON COLUMN public.rs485_weather_10min.rain_total_mm_x1000 IS '桶末设备累计降雨(自开机起累加,0..FFFF回绕),单位mm×1000';
|
|
||||||
COMMENT ON COLUMN public.rs485_weather_10min.solar_wm2_x100 IS '10分钟平均太阳辐射,单位W/m²×100';
|
|
||||||
COMMENT ON COLUMN public.rs485_weather_10min.uv_index IS '10分钟平均紫外线指数';
|
|
||||||
COMMENT ON COLUMN public.rs485_weather_10min.pressure_hpa_x100 IS '10分钟平均气压,单位hPa×100';
|
|
||||||
COMMENT ON COLUMN public.rs485_weather_10min.sample_count IS '10分钟样本数量';
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: forecast_hourly; Type: TABLE; Schema: public; Owner: -
|
|
||||||
-- 用途:小时级预报(版本化:issued_at 为预报方案发布时间)
|
|
||||||
--
|
|
||||||
CREATE TABLE IF NOT EXISTS public.forecast_hourly (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
station_id character varying(50) NOT NULL,
|
|
||||||
provider character varying(50) NOT NULL,
|
|
||||||
issued_at timestamp with time zone NOT NULL,
|
|
||||||
forecast_time timestamp with time zone NOT NULL,
|
|
||||||
temp_c_x100 integer,
|
|
||||||
humidity_pct integer,
|
|
||||||
wind_speed_ms_x1000 integer,
|
|
||||||
wind_gust_ms_x1000 integer,
|
|
||||||
wind_dir_deg integer,
|
|
||||||
rain_mm_x1000 integer,
|
|
||||||
precip_prob_pct integer,
|
|
||||||
uv_index integer,
|
|
||||||
pressure_hpa_x100 integer
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 约束与索引
|
|
||||||
ALTER TABLE ONLY public.forecast_hourly
|
|
||||||
ADD CONSTRAINT forecast_hourly_udx UNIQUE (station_id, provider, issued_at, forecast_time);
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.forecast_hourly
|
|
||||||
ADD CONSTRAINT forecast_hourly_station_id_fkey FOREIGN KEY (station_id) REFERENCES public.stations(station_id);
|
|
||||||
|
|
||||||
CREATE INDEX idx_fcast_station_time ON public.forecast_hourly USING btree (station_id, forecast_time);
|
|
||||||
|
|
||||||
-- 注释
|
|
||||||
COMMENT ON TABLE public.forecast_hourly IS '小时级预报表,按issued_at版本化;要素使用缩放整数存储';
|
|
||||||
COMMENT ON COLUMN public.forecast_hourly.issued_at IS '预报方案发布时间(版本时间)';
|
|
||||||
COMMENT ON COLUMN public.forecast_hourly.forecast_time IS '目标小时时间戳';
|
|
||||||
COMMENT ON COLUMN public.forecast_hourly.rain_mm_x1000 IS '该小时降雨量,单位mm×1000';
|
|
||||||
298
gin_server.go
Normal file
298
gin_server.go
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"weatherstation/config"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ginDB *sql.DB
|
||||||
|
|
||||||
|
func initGinDB() error {
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
|
||||||
|
connStr := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
|
||||||
|
cfg.Database.Host, cfg.Database.Port, cfg.Database.User,
|
||||||
|
cfg.Database.Password, cfg.Database.DBName, cfg.Database.SSLMode)
|
||||||
|
|
||||||
|
var err error
|
||||||
|
ginDB, err = sql.Open("postgres", connStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("无法连接到数据库: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ginDB.Ping()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("数据库连接测试失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取在线设备数量
|
||||||
|
func getOnlineDevicesCount() int {
|
||||||
|
if ginDB == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT COUNT(DISTINCT station_id)
|
||||||
|
FROM rs485_weather_data
|
||||||
|
WHERE timestamp > NOW() - INTERVAL '5 minutes'`
|
||||||
|
|
||||||
|
var count int
|
||||||
|
err := ginDB.QueryRow(query).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主页面处理器
|
||||||
|
func indexHandler(c *gin.Context) {
|
||||||
|
data := PageData{
|
||||||
|
Title: "英卓气象站",
|
||||||
|
ServerTime: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
|
OnlineDevices: getOnlineDevicesCount(),
|
||||||
|
TiandituKey: "0c260b8a094a4e0bc507808812cefdac",
|
||||||
|
}
|
||||||
|
c.HTML(http.StatusOK, "index.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 系统状态API
|
||||||
|
func systemStatusHandler(c *gin.Context) {
|
||||||
|
status := SystemStatus{
|
||||||
|
OnlineDevices: getOnlineDevicesCount(),
|
||||||
|
ServerTime: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取WH65LP站点列表API
|
||||||
|
func getStationsHandler(c *gin.Context) {
|
||||||
|
query := `
|
||||||
|
SELECT DISTINCT s.station_id,
|
||||||
|
COALESCE(s.password, '') as station_name,
|
||||||
|
'WH65LP' as device_type,
|
||||||
|
COALESCE(MAX(r.timestamp), '1970-01-01'::timestamp) as last_update,
|
||||||
|
COALESCE(s.latitude, 0) as latitude,
|
||||||
|
COALESCE(s.longitude, 0) as longitude,
|
||||||
|
COALESCE(s.name, '') as name,
|
||||||
|
COALESCE(s.location, '') as location
|
||||||
|
FROM stations s
|
||||||
|
LEFT JOIN rs485_weather_data r ON s.station_id = r.station_id
|
||||||
|
WHERE s.station_id LIKE 'RS485-%'
|
||||||
|
GROUP BY s.station_id, s.password, s.latitude, s.longitude, s.name, s.location
|
||||||
|
ORDER BY s.station_id`
|
||||||
|
|
||||||
|
rows, err := ginDB.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询站点失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var stations []Station
|
||||||
|
for rows.Next() {
|
||||||
|
var station Station
|
||||||
|
var lastUpdate time.Time
|
||||||
|
err := rows.Scan(
|
||||||
|
&station.StationID,
|
||||||
|
&station.StationName,
|
||||||
|
&station.DeviceType,
|
||||||
|
&lastUpdate,
|
||||||
|
&station.Latitude,
|
||||||
|
&station.Longitude,
|
||||||
|
&station.Name,
|
||||||
|
&station.Location,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
station.LastUpdate = lastUpdate.Format("2006-01-02 15:04:05")
|
||||||
|
|
||||||
|
// 从station_id中提取十六进制ID并转换为十进制
|
||||||
|
if len(station.StationID) > 6 {
|
||||||
|
hexID := station.StationID[len(station.StationID)-6:]
|
||||||
|
if decimalID, err := strconv.ParseInt(hexID, 16, 64); err == nil {
|
||||||
|
station.DecimalID = strconv.FormatInt(decimalID, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stations = append(stations, station)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, stations)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取历史数据API
|
||||||
|
func getDataHandler(c *gin.Context) {
|
||||||
|
// 获取查询参数
|
||||||
|
decimalID := c.Query("decimal_id")
|
||||||
|
startTime := c.Query("start_time")
|
||||||
|
endTime := c.Query("end_time")
|
||||||
|
interval := c.Query("interval")
|
||||||
|
|
||||||
|
// 将十进制ID转换为十六进制(补足6位)
|
||||||
|
decimalNum, err := strconv.ParseInt(decimalID, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的站点编号"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hexID := fmt.Sprintf("%06X", decimalNum)
|
||||||
|
stationID := fmt.Sprintf("RS485-%s", hexID)
|
||||||
|
|
||||||
|
// 构建查询SQL
|
||||||
|
var query string
|
||||||
|
switch interval {
|
||||||
|
case "10min":
|
||||||
|
query = `
|
||||||
|
WITH grouped_data AS (
|
||||||
|
SELECT
|
||||||
|
date_trunc('hour', timestamp) +
|
||||||
|
(floor(date_part('minute', timestamp) / 10) * interval '10 minute') as time_group,
|
||||||
|
AVG(temperature) as temperature,
|
||||||
|
AVG(humidity) as humidity,
|
||||||
|
AVG(pressure) as pressure,
|
||||||
|
AVG(wind_speed) as wind_speed,
|
||||||
|
AVG(wind_direction) as wind_direction,
|
||||||
|
MAX(rainfall) - MIN(rainfall) as rainfall,
|
||||||
|
AVG(light) as light,
|
||||||
|
AVG(uv) as uv
|
||||||
|
FROM rs485_weather_data
|
||||||
|
WHERE station_id = $1 AND timestamp BETWEEN $2 AND $3
|
||||||
|
GROUP BY time_group
|
||||||
|
ORDER BY time_group
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
to_char(time_group, 'YYYY-MM-DD HH24:MI:SS') as date_time,
|
||||||
|
temperature, humidity, pressure, wind_speed, wind_direction,
|
||||||
|
CASE WHEN rainfall < 0 THEN 0 ELSE rainfall END as rainfall,
|
||||||
|
light, uv
|
||||||
|
FROM grouped_data
|
||||||
|
ORDER BY time_group`
|
||||||
|
case "30min":
|
||||||
|
query = `
|
||||||
|
WITH grouped_data AS (
|
||||||
|
SELECT
|
||||||
|
date_trunc('hour', timestamp) +
|
||||||
|
(floor(date_part('minute', timestamp) / 30) * interval '30 minute') as time_group,
|
||||||
|
AVG(temperature) as temperature,
|
||||||
|
AVG(humidity) as humidity,
|
||||||
|
AVG(pressure) as pressure,
|
||||||
|
AVG(wind_speed) as wind_speed,
|
||||||
|
AVG(wind_direction) as wind_direction,
|
||||||
|
MAX(rainfall) - MIN(rainfall) as rainfall,
|
||||||
|
AVG(light) as light,
|
||||||
|
AVG(uv) as uv
|
||||||
|
FROM rs485_weather_data
|
||||||
|
WHERE station_id = $1 AND timestamp BETWEEN $2 AND $3
|
||||||
|
GROUP BY time_group
|
||||||
|
ORDER BY time_group
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
to_char(time_group, 'YYYY-MM-DD HH24:MI:SS') as date_time,
|
||||||
|
temperature, humidity, pressure, wind_speed, wind_direction,
|
||||||
|
CASE WHEN rainfall < 0 THEN 0 ELSE rainfall END as rainfall,
|
||||||
|
light, uv
|
||||||
|
FROM grouped_data
|
||||||
|
ORDER BY time_group`
|
||||||
|
default: // 1hour
|
||||||
|
query = `
|
||||||
|
WITH grouped_data AS (
|
||||||
|
SELECT
|
||||||
|
date_trunc('hour', timestamp) as time_group,
|
||||||
|
AVG(temperature) as temperature,
|
||||||
|
AVG(humidity) as humidity,
|
||||||
|
AVG(pressure) as pressure,
|
||||||
|
AVG(wind_speed) as wind_speed,
|
||||||
|
AVG(wind_direction) as wind_direction,
|
||||||
|
MAX(rainfall) - MIN(rainfall) as rainfall,
|
||||||
|
AVG(light) as light,
|
||||||
|
AVG(uv) as uv
|
||||||
|
FROM rs485_weather_data
|
||||||
|
WHERE station_id = $1 AND timestamp BETWEEN $2 AND $3
|
||||||
|
GROUP BY time_group
|
||||||
|
ORDER BY time_group
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
to_char(time_group, 'YYYY-MM-DD HH24:MI:SS') as date_time,
|
||||||
|
temperature, humidity, pressure, wind_speed, wind_direction,
|
||||||
|
CASE WHEN rainfall < 0 THEN 0 ELSE rainfall END as rainfall,
|
||||||
|
light, uv
|
||||||
|
FROM grouped_data
|
||||||
|
ORDER BY time_group`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行查询
|
||||||
|
rows, err := ginDB.Query(query, stationID, startTime, endTime)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询数据失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var weatherPoints []WeatherPoint
|
||||||
|
for rows.Next() {
|
||||||
|
var point WeatherPoint
|
||||||
|
err := rows.Scan(
|
||||||
|
&point.DateTime,
|
||||||
|
&point.Temperature,
|
||||||
|
&point.Humidity,
|
||||||
|
&point.Pressure,
|
||||||
|
&point.WindSpeed,
|
||||||
|
&point.WindDir,
|
||||||
|
&point.Rainfall,
|
||||||
|
&point.Light,
|
||||||
|
&point.UV,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
weatherPoints = append(weatherPoints, point)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, weatherPoints)
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartGinServer() {
|
||||||
|
err := initGinDB()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("初始化Gin数据库连接失败: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置Gin模式
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
|
||||||
|
// 创建Gin引擎
|
||||||
|
r := gin.Default()
|
||||||
|
|
||||||
|
// 加载HTML模板
|
||||||
|
r.LoadHTMLGlob("templates/*")
|
||||||
|
|
||||||
|
// 静态文件服务
|
||||||
|
r.Static("/static", "./static")
|
||||||
|
|
||||||
|
// 路由设置
|
||||||
|
r.GET("/", indexHandler)
|
||||||
|
|
||||||
|
// API路由组
|
||||||
|
api := r.Group("/api")
|
||||||
|
{
|
||||||
|
api.GET("/system/status", systemStatusHandler)
|
||||||
|
api.GET("/stations", getStationsHandler)
|
||||||
|
api.GET("/data", getDataHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动服务器
|
||||||
|
fmt.Println("Gin Web服务器启动,监听端口 10003...")
|
||||||
|
r.Run(":10003")
|
||||||
|
}
|
||||||
4
go.mod
4
go.mod
@ -5,7 +5,6 @@ go 1.23.0
|
|||||||
toolchain go1.24.5
|
toolchain go1.24.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.10.1
|
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
@ -17,6 +16,7 @@ require (
|
|||||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
|
github.com/gin-gonic/gin v1.10.1 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||||
@ -36,4 +36,4 @@ require (
|
|||||||
golang.org/x/sys v0.20.0 // indirect
|
golang.org/x/sys v0.20.0 // indirect
|
||||||
golang.org/x/text v0.15.0 // indirect
|
golang.org/x/text v0.15.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.1 // indirect
|
google.golang.org/protobuf v1.34.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,87 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ServerConfig struct {
|
|
||||||
WebPort int `yaml:"web_port"` // Gin Web服务器端口
|
|
||||||
UDPPort int `yaml:"udp_port"` // UDP服务器端口
|
|
||||||
}
|
|
||||||
|
|
||||||
type DatabaseConfig struct {
|
|
||||||
Host string `yaml:"host"`
|
|
||||||
Port int `yaml:"port"`
|
|
||||||
User string `yaml:"user"`
|
|
||||||
Password string `yaml:"password"`
|
|
||||||
DBName string `yaml:"dbname"`
|
|
||||||
SSLMode string `yaml:"sslmode"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
Server ServerConfig `yaml:"server"`
|
|
||||||
Database DatabaseConfig `yaml:"database"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
instance *Config
|
|
||||||
once sync.Once
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetConfig 返回配置单例
|
|
||||||
func GetConfig() *Config {
|
|
||||||
once.Do(func() {
|
|
||||||
instance = &Config{}
|
|
||||||
if err := instance.loadConfig(); err != nil {
|
|
||||||
panic(fmt.Sprintf("加载配置文件失败: %v", err))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return instance
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadConfig 从配置文件加载配置
|
|
||||||
func (c *Config) loadConfig() error {
|
|
||||||
// 尝试多个位置查找配置文件
|
|
||||||
configPaths := []string{
|
|
||||||
"config.yaml", // 当前目录
|
|
||||||
"../config.yaml", // 上级目录
|
|
||||||
"../../config.yaml", // 项目根目录
|
|
||||||
filepath.Join(os.Getenv("HOME"), ".weatherstation/config.yaml"), // 用户目录
|
|
||||||
}
|
|
||||||
|
|
||||||
var data []byte
|
|
||||||
var err error
|
|
||||||
for _, path := range configPaths {
|
|
||||||
if data, err = os.ReadFile(path); err == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("未找到配置文件: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := yaml.Unmarshal(data, c); err != nil {
|
|
||||||
return fmt.Errorf("解析配置文件失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.validate()
|
|
||||||
}
|
|
||||||
|
|
||||||
// validate 验证配置有效性
|
|
||||||
func (c *Config) validate() error {
|
|
||||||
if c.Server.WebPort <= 0 {
|
|
||||||
c.Server.WebPort = 10003 // 默认Web端口
|
|
||||||
}
|
|
||||||
if c.Server.UDPPort <= 0 {
|
|
||||||
c.Server.UDPPort = 10001 // 默认UDP端口
|
|
||||||
}
|
|
||||||
if c.Database.SSLMode == "" {
|
|
||||||
c.Database.SSLMode = "disable" // 默认禁用SSL
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
"weatherstation/internal/config"
|
|
||||||
|
|
||||||
_ "github.com/lib/pq"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
instance *sql.DB
|
|
||||||
once sync.Once
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetDB 返回数据库连接单例
|
|
||||||
func GetDB() *sql.DB {
|
|
||||||
once.Do(func() {
|
|
||||||
cfg := config.GetConfig()
|
|
||||||
connStr := fmt.Sprintf(
|
|
||||||
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
|
|
||||||
cfg.Database.Host,
|
|
||||||
cfg.Database.Port,
|
|
||||||
cfg.Database.User,
|
|
||||||
cfg.Database.Password,
|
|
||||||
cfg.Database.DBName,
|
|
||||||
cfg.Database.SSLMode,
|
|
||||||
)
|
|
||||||
|
|
||||||
var err error
|
|
||||||
instance, err = sql.Open("postgres", connStr)
|
|
||||||
if err != nil {
|
|
||||||
panic(fmt.Sprintf("无法连接到数据库: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = instance.Ping(); err != nil {
|
|
||||||
panic(fmt.Sprintf("数据库连接测试失败: %v", err))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return instance
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close 关闭数据库连接
|
|
||||||
func Close() error {
|
|
||||||
if instance != nil {
|
|
||||||
return instance.Close()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@ -1,242 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"time"
|
|
||||||
"weatherstation/pkg/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetOnlineDevicesCount 获取在线设备数量
|
|
||||||
func GetOnlineDevicesCount(db *sql.DB) int {
|
|
||||||
query := `
|
|
||||||
SELECT COUNT(DISTINCT station_id)
|
|
||||||
FROM rs485_weather_data
|
|
||||||
WHERE timestamp > NOW() - INTERVAL '5 minutes'`
|
|
||||||
|
|
||||||
var count int
|
|
||||||
if err := db.QueryRow(query).Scan(&count); err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStations 获取所有WH65LP站点列表
|
|
||||||
func GetStations(db *sql.DB) ([]types.Station, error) {
|
|
||||||
query := `
|
|
||||||
SELECT DISTINCT s.station_id,
|
|
||||||
COALESCE(s.password, '') as station_name,
|
|
||||||
'WH65LP' as device_type,
|
|
||||||
COALESCE(MAX(r.timestamp), '1970-01-01'::timestamp) as last_update,
|
|
||||||
COALESCE(s.latitude, 0) as latitude,
|
|
||||||
COALESCE(s.longitude, 0) as longitude,
|
|
||||||
COALESCE(s.name, '') as name,
|
|
||||||
COALESCE(s.location, '') as location
|
|
||||||
FROM stations s
|
|
||||||
LEFT JOIN rs485_weather_data r ON s.station_id = r.station_id
|
|
||||||
WHERE s.station_id LIKE 'RS485-%'
|
|
||||||
GROUP BY s.station_id, s.password, s.latitude, s.longitude, s.name, s.location
|
|
||||||
ORDER BY s.station_id`
|
|
||||||
|
|
||||||
rows, err := db.Query(query)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var stations []types.Station
|
|
||||||
for rows.Next() {
|
|
||||||
var station types.Station
|
|
||||||
var lastUpdate time.Time
|
|
||||||
err := rows.Scan(
|
|
||||||
&station.StationID,
|
|
||||||
&station.StationName,
|
|
||||||
&station.DeviceType,
|
|
||||||
&lastUpdate,
|
|
||||||
&station.Latitude,
|
|
||||||
&station.Longitude,
|
|
||||||
&station.Name,
|
|
||||||
&station.Location,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
station.LastUpdate = lastUpdate.Format("2006-01-02 15:04:05")
|
|
||||||
stations = append(stations, station)
|
|
||||||
}
|
|
||||||
|
|
||||||
return stations, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetWeatherData 获取指定站点的历史天气数据
|
|
||||||
func GetWeatherData(db *sql.DB, stationID string, startTime, endTime time.Time, interval string) ([]types.WeatherPoint, error) {
|
|
||||||
// 构建查询SQL(统一风向矢量平均,雨量为累计量的正增量求和)
|
|
||||||
var query string
|
|
||||||
var intervalStr string
|
|
||||||
switch interval {
|
|
||||||
case "10min":
|
|
||||||
intervalStr = "10 minutes"
|
|
||||||
case "30min":
|
|
||||||
intervalStr = "30 minutes"
|
|
||||||
default: // 1hour
|
|
||||||
intervalStr = "1 hour"
|
|
||||||
}
|
|
||||||
query = buildWeatherDataQuery(intervalStr)
|
|
||||||
|
|
||||||
rows, err := db.Query(query, intervalStr, stationID, startTime, endTime)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var points []types.WeatherPoint
|
|
||||||
for rows.Next() {
|
|
||||||
var point types.WeatherPoint
|
|
||||||
err := rows.Scan(
|
|
||||||
&point.DateTime,
|
|
||||||
&point.Temperature,
|
|
||||||
&point.Humidity,
|
|
||||||
&point.Pressure,
|
|
||||||
&point.WindSpeed,
|
|
||||||
&point.WindDir,
|
|
||||||
&point.Rainfall,
|
|
||||||
&point.Light,
|
|
||||||
&point.UV,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
points = append(points, point)
|
|
||||||
}
|
|
||||||
|
|
||||||
return points, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSeriesFrom10Min 基于10分钟聚合表返回 10m/30m/1h 数据(风向向量平均、降雨求和、加权平均)
|
|
||||||
func GetSeriesFrom10Min(db *sql.DB, stationID string, startTime, endTime time.Time, interval string) ([]types.WeatherPoint, error) {
|
|
||||||
var query string
|
|
||||||
switch interval {
|
|
||||||
case "10min":
|
|
||||||
query = `
|
|
||||||
SELECT
|
|
||||||
to_char(bucket_start, 'YYYY-MM-DD HH24:MI:SS') AS date_time,
|
|
||||||
ROUND(temp_c_x100/100.0, 2) AS temperature,
|
|
||||||
ROUND(humidity_pct::numeric, 2) AS humidity,
|
|
||||||
ROUND(pressure_hpa_x100/100.0, 2) AS pressure,
|
|
||||||
ROUND(wind_speed_ms_x1000/1000.0, 3) AS wind_speed,
|
|
||||||
ROUND(wind_dir_deg::numeric, 2) AS wind_direction,
|
|
||||||
ROUND(rain_10m_mm_x1000/1000.0, 3) AS rainfall,
|
|
||||||
ROUND(solar_wm2_x100/100.0, 2) AS light,
|
|
||||||
ROUND(uv_index::numeric, 2) AS uv
|
|
||||||
FROM rs485_weather_10min
|
|
||||||
WHERE station_id = $1 AND bucket_start >= $2 AND bucket_start <= $3
|
|
||||||
ORDER BY bucket_start`
|
|
||||||
case "30min":
|
|
||||||
query = buildAggFrom10MinQuery("30 minutes")
|
|
||||||
default: // 1hour
|
|
||||||
query = buildAggFrom10MinQuery("1 hour")
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := db.Query(query, stationID, startTime, endTime)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var points []types.WeatherPoint
|
|
||||||
for rows.Next() {
|
|
||||||
var p types.WeatherPoint
|
|
||||||
if err := rows.Scan(&p.DateTime, &p.Temperature, &p.Humidity, &p.Pressure, &p.WindSpeed, &p.WindDir, &p.Rainfall, &p.Light, &p.UV); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
points = append(points, p)
|
|
||||||
}
|
|
||||||
return points, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildAggFrom10MinQuery 返回从10分钟表再聚合的SQL(interval 支持 '30 minutes' 或 '1 hour')
|
|
||||||
func buildAggFrom10MinQuery(interval string) string {
|
|
||||||
return `
|
|
||||||
WITH base AS (
|
|
||||||
SELECT * FROM rs485_weather_10min
|
|
||||||
WHERE station_id = $1 AND bucket_start >= $2 AND bucket_start <= $3
|
|
||||||
), g AS (
|
|
||||||
SELECT
|
|
||||||
date_trunc('hour', bucket_start) + floor(extract(epoch from (bucket_start - date_trunc('hour', bucket_start)))/extract(epoch from '` + interval + `'::interval)) * '` + interval + `'::interval AS grp,
|
|
||||||
SUM(temp_c_x100 * sample_count)::bigint AS w_temp,
|
|
||||||
SUM(humidity_pct * sample_count)::bigint AS w_hum,
|
|
||||||
SUM(pressure_hpa_x100 * sample_count)::bigint AS w_p,
|
|
||||||
SUM(solar_wm2_x100 * sample_count)::bigint AS w_solar,
|
|
||||||
SUM(uv_index * sample_count)::bigint AS w_uv,
|
|
||||||
SUM(wind_speed_ms_x1000 * sample_count)::bigint AS w_ws,
|
|
||||||
MAX(wind_gust_ms_x1000) AS gust_max,
|
|
||||||
SUM(sin(radians(wind_dir_deg)) * sample_count)::double precision AS sin_sum,
|
|
||||||
SUM(cos(radians(wind_dir_deg)) * sample_count)::double precision AS cos_sum,
|
|
||||||
SUM(rain_10m_mm_x1000) AS rain_sum,
|
|
||||||
SUM(sample_count) AS n_sum
|
|
||||||
FROM base
|
|
||||||
GROUP BY 1
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
to_char(grp, 'YYYY-MM-DD HH24:MI:SS') AS date_time,
|
|
||||||
ROUND((w_temp/NULLIF(n_sum,0))/100.0, 2) AS temperature,
|
|
||||||
ROUND((w_hum/NULLIF(n_sum,0))::numeric, 2) AS humidity,
|
|
||||||
ROUND((w_p/NULLIF(n_sum,0))/100.0, 2) AS pressure,
|
|
||||||
ROUND((w_ws/NULLIF(n_sum,0))/1000.0, 3) AS wind_speed,
|
|
||||||
ROUND((CASE WHEN degrees(atan2(sin_sum, cos_sum)) < 0
|
|
||||||
THEN degrees(atan2(sin_sum, cos_sum)) + 360
|
|
||||||
ELSE degrees(atan2(sin_sum, cos_sum)) END)::numeric, 2) AS wind_direction,
|
|
||||||
ROUND((rain_sum/1000.0)::numeric, 3) AS rainfall,
|
|
||||||
ROUND((w_solar/NULLIF(n_sum,0))/100.0, 2) AS light,
|
|
||||||
ROUND((w_uv/NULLIF(n_sum,0))::numeric, 2) AS uv
|
|
||||||
FROM g
|
|
||||||
ORDER BY grp`
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildWeatherDataQuery 构建天气数据查询SQL
|
|
||||||
func buildWeatherDataQuery(interval string) string {
|
|
||||||
return `
|
|
||||||
WITH base AS (
|
|
||||||
SELECT
|
|
||||||
date_trunc('hour', timestamp) +
|
|
||||||
(floor(date_part('minute', timestamp) / extract(epoch from $1::interval) * 60) * $1::interval) as time_group,
|
|
||||||
timestamp as ts,
|
|
||||||
temperature, humidity, pressure, wind_speed, wind_direction, rainfall, light, uv
|
|
||||||
FROM rs485_weather_data
|
|
||||||
WHERE station_id = $2 AND timestamp BETWEEN $3 AND $4
|
|
||||||
),
|
|
||||||
rain_inc AS (
|
|
||||||
SELECT time_group, GREATEST(rainfall - LAG(rainfall) OVER (PARTITION BY time_group ORDER BY ts), 0) AS inc
|
|
||||||
FROM base
|
|
||||||
),
|
|
||||||
rain_sum AS (
|
|
||||||
SELECT time_group, SUM(inc) AS rainfall
|
|
||||||
FROM rain_inc
|
|
||||||
GROUP BY time_group
|
|
||||||
),
|
|
||||||
grouped_data AS (
|
|
||||||
SELECT
|
|
||||||
time_group,
|
|
||||||
AVG(temperature) as temperature,
|
|
||||||
AVG(humidity) as humidity,
|
|
||||||
AVG(pressure) as pressure,
|
|
||||||
AVG(wind_speed) as wind_speed,
|
|
||||||
DEGREES(ATAN2(AVG(SIN(RADIANS(wind_direction))), AVG(COS(RADIANS(wind_direction))))) AS wind_direction_raw,
|
|
||||||
AVG(light) as light,
|
|
||||||
AVG(uv) as uv
|
|
||||||
FROM base
|
|
||||||
GROUP BY time_group
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
to_char(g.time_group, 'YYYY-MM-DD HH24:MI:SS') as date_time,
|
|
||||||
ROUND(g.temperature::numeric, 2) as temperature,
|
|
||||||
ROUND(g.humidity::numeric, 2) as humidity,
|
|
||||||
ROUND(g.pressure::numeric, 2) as pressure,
|
|
||||||
ROUND(g.wind_speed::numeric, 2) as wind_speed,
|
|
||||||
ROUND((CASE WHEN g.wind_direction_raw < 0 THEN g.wind_direction_raw + 360 ELSE g.wind_direction_raw END)::numeric, 2) AS wind_direction,
|
|
||||||
ROUND(COALESCE(r.rainfall, 0)::numeric, 3) as rainfall,
|
|
||||||
ROUND(g.light::numeric, 2) as light,
|
|
||||||
ROUND(g.uv::numeric, 2) as uv
|
|
||||||
FROM grouped_data g
|
|
||||||
LEFT JOIN rain_sum r ON r.time_group = g.time_group
|
|
||||||
ORDER BY g.time_group`
|
|
||||||
}
|
|
||||||
@ -1,130 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
"weatherstation/internal/config"
|
|
||||||
"weatherstation/internal/database"
|
|
||||||
"weatherstation/pkg/types"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// StartGinServer 启动Gin Web服务器
|
|
||||||
func StartGinServer() error {
|
|
||||||
// 设置Gin模式
|
|
||||||
gin.SetMode(gin.ReleaseMode)
|
|
||||||
|
|
||||||
// 创建Gin引擎
|
|
||||||
r := gin.Default()
|
|
||||||
|
|
||||||
// 加载HTML模板
|
|
||||||
r.LoadHTMLGlob("templates/*")
|
|
||||||
|
|
||||||
// 静态文件服务
|
|
||||||
r.Static("/static", "./static")
|
|
||||||
|
|
||||||
// 路由设置
|
|
||||||
r.GET("/", indexHandler)
|
|
||||||
|
|
||||||
// API路由组
|
|
||||||
api := r.Group("/api")
|
|
||||||
{
|
|
||||||
api.GET("/system/status", systemStatusHandler)
|
|
||||||
api.GET("/stations", getStationsHandler)
|
|
||||||
api.GET("/data", getDataHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取配置的Web端口
|
|
||||||
port := config.GetConfig().Server.WebPort
|
|
||||||
if port == 0 {
|
|
||||||
port = 10003 // 默认端口
|
|
||||||
}
|
|
||||||
|
|
||||||
// 启动服务器
|
|
||||||
fmt.Printf("Gin Web服务器启动,监听端口 %d...\n", port)
|
|
||||||
return r.Run(fmt.Sprintf(":%d", port))
|
|
||||||
}
|
|
||||||
|
|
||||||
// indexHandler 处理主页请求
|
|
||||||
func indexHandler(c *gin.Context) {
|
|
||||||
data := types.PageData{
|
|
||||||
Title: "英卓气象站",
|
|
||||||
ServerTime: time.Now().Format("2006-01-02 15:04:05"),
|
|
||||||
OnlineDevices: database.GetOnlineDevicesCount(database.GetDB()),
|
|
||||||
TiandituKey: "0c260b8a094a4e0bc507808812cefdac",
|
|
||||||
}
|
|
||||||
c.HTML(http.StatusOK, "index.html", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// systemStatusHandler 处理系统状态API请求
|
|
||||||
func systemStatusHandler(c *gin.Context) {
|
|
||||||
status := types.SystemStatus{
|
|
||||||
OnlineDevices: database.GetOnlineDevicesCount(database.GetDB()),
|
|
||||||
ServerTime: time.Now().Format("2006-01-02 15:04:05"),
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getStationsHandler 处理获取站点列表API请求
|
|
||||||
func getStationsHandler(c *gin.Context) {
|
|
||||||
stations, err := database.GetStations(database.GetDB())
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询站点失败"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 为每个站点计算十进制ID
|
|
||||||
for i := range stations {
|
|
||||||
if len(stations[i].StationID) > 6 {
|
|
||||||
hexID := stations[i].StationID[len(stations[i].StationID)-6:]
|
|
||||||
if decimalID, err := strconv.ParseInt(hexID, 16, 64); err == nil {
|
|
||||||
stations[i].DecimalID = strconv.FormatInt(decimalID, 10)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, stations)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getDataHandler 处理获取历史数据API请求
|
|
||||||
func getDataHandler(c *gin.Context) {
|
|
||||||
// 获取查询参数
|
|
||||||
decimalID := c.Query("decimal_id")
|
|
||||||
startTime := c.Query("start_time")
|
|
||||||
endTime := c.Query("end_time")
|
|
||||||
interval := c.Query("interval")
|
|
||||||
|
|
||||||
// 将十进制ID转换为十六进制(补足6位)
|
|
||||||
decimalNum, err := strconv.ParseInt(decimalID, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的站点编号"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hexID := fmt.Sprintf("%06X", decimalNum)
|
|
||||||
stationID := fmt.Sprintf("RS485-%s", hexID)
|
|
||||||
|
|
||||||
// 解析时间
|
|
||||||
start, err := time.Parse("2006-01-02 15:04:05", startTime)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的开始时间"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
end, err := time.Parse("2006-01-02 15:04:05", endTime)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的结束时间"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取数据(改为基于10分钟聚合表的再聚合)
|
|
||||||
points, err := database.GetSeriesFrom10Min(database.GetDB(), stationID, start, end, interval)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询数据失败"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, points)
|
|
||||||
}
|
|
||||||
@ -1,298 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"context"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
"unicode/utf8"
|
|
||||||
"weatherstation/internal/config"
|
|
||||||
"weatherstation/internal/tools"
|
|
||||||
"weatherstation/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// UTF8Writer 包装一个io.Writer,确保写入的数据是有效的UTF-8
|
|
||||||
type UTF8Writer struct {
|
|
||||||
w io.Writer
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewUTF8Writer 创建一个新的UTF8Writer
|
|
||||||
func NewUTF8Writer(w io.Writer) *UTF8Writer {
|
|
||||||
return &UTF8Writer{w: w}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write 实现io.Writer接口
|
|
||||||
func (w *UTF8Writer) Write(p []byte) (n int, err error) {
|
|
||||||
if utf8.Valid(p) {
|
|
||||||
return w.w.Write(p)
|
|
||||||
}
|
|
||||||
s := string(p)
|
|
||||||
s = strings.ToValidUTF8(s, "")
|
|
||||||
return w.w.Write([]byte(s))
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
logFile *os.File
|
|
||||||
logFileMutex sync.Mutex
|
|
||||||
currentLogDay int
|
|
||||||
)
|
|
||||||
|
|
||||||
// getLogFileName 获取当前日期的日志文件名
|
|
||||||
func getLogFileName() string {
|
|
||||||
currentTime := time.Now()
|
|
||||||
return filepath.Join("log", fmt.Sprintf("%s.log", currentTime.Format("2006-01-02")))
|
|
||||||
}
|
|
||||||
|
|
||||||
// openLogFile 打开日志文件
|
|
||||||
func openLogFile() (*os.File, error) {
|
|
||||||
logDir := "log"
|
|
||||||
if _, err := os.Stat(logDir); os.IsNotExist(err) {
|
|
||||||
os.MkdirAll(logDir, 0755)
|
|
||||||
}
|
|
||||||
|
|
||||||
logFileName := getLogFileName()
|
|
||||||
return os.OpenFile(logFileName, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetupLogger 设置日志系统
|
|
||||||
func SetupLogger() {
|
|
||||||
var err error
|
|
||||||
logFile, err = openLogFile()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("无法创建日志文件: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
currentLogDay = time.Now().Day()
|
|
||||||
|
|
||||||
bufferedWriter := bufio.NewWriter(logFile)
|
|
||||||
utf8Writer := NewUTF8Writer(bufferedWriter)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
time.Sleep(1 * time.Second)
|
|
||||||
|
|
||||||
logFileMutex.Lock()
|
|
||||||
bufferedWriter.Flush()
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
if now.Day() != currentLogDay {
|
|
||||||
oldLogFile := logFile
|
|
||||||
logFile, err = openLogFile()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("无法创建新日志文件: %v", err)
|
|
||||||
} else {
|
|
||||||
oldLogFile.Close()
|
|
||||||
currentLogDay = now.Day()
|
|
||||||
bufferedWriter = bufio.NewWriter(logFile)
|
|
||||||
utf8Writer = NewUTF8Writer(bufferedWriter)
|
|
||||||
log.SetOutput(io.MultiWriter(os.Stdout, utf8Writer))
|
|
||||||
log.Println("日志文件已轮转")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logFileMutex.Unlock()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
multiWriter := io.MultiWriter(os.Stdout, utf8Writer)
|
|
||||||
log.SetOutput(multiWriter)
|
|
||||||
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartUDPServer 启动UDP服务器
|
|
||||||
func StartUDPServer() error {
|
|
||||||
cfg := config.GetConfig()
|
|
||||||
addr := fmt.Sprintf(":%d", cfg.Server.UDPPort)
|
|
||||||
conn, err := net.ListenPacket("udp", addr)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("无法监听UDP端口 %d: %v", cfg.Server.UDPPort, err)
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
log.Printf("UDP服务器已启动,监听端口 %d...", cfg.Server.UDPPort)
|
|
||||||
buffer := make([]byte, 2048)
|
|
||||||
|
|
||||||
// 后台定时:每分钟回刷最近30分钟(全站)
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
now := time.Now()
|
|
||||||
from := now.Add(-30 * time.Minute)
|
|
||||||
ctx := context.Background()
|
|
||||||
if err := tools.RunBackfill10Min(ctx, tools.BackfillOptions{
|
|
||||||
FromTime: from,
|
|
||||||
ToTime: now,
|
|
||||||
WrapCycleMM: 0,
|
|
||||||
BucketMinutes: 10,
|
|
||||||
}); err != nil {
|
|
||||||
log.Printf("定时回填失败: %v", err)
|
|
||||||
}
|
|
||||||
time.Sleep(1 * time.Minute)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
for {
|
|
||||||
n, addr, err := conn.ReadFrom(buffer)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("读取数据错误: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rawData := buffer[:n]
|
|
||||||
log.Printf("从 %s 接收到 %d 字节数据", addr.String(), n)
|
|
||||||
|
|
||||||
hexDump := hexDump(rawData)
|
|
||||||
log.Printf("原始码流(十六进制):\n%s", hexDump)
|
|
||||||
asciiDump := asciiDump(rawData)
|
|
||||||
log.Printf("ASCII码:\n%s", asciiDump)
|
|
||||||
|
|
||||||
if len(rawData) == 25 && rawData[0] == 0x24 {
|
|
||||||
handleRS485Data(rawData, addr, hexDump)
|
|
||||||
} else {
|
|
||||||
handleWiFiData(rawData, addr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleRS485Data 处理RS485设备数据
|
|
||||||
func handleRS485Data(rawData []byte, addr net.Addr, hexDump string) {
|
|
||||||
log.Println("485 型气象站数据")
|
|
||||||
|
|
||||||
// 生成源码字符串(用于日志记录)
|
|
||||||
sourceHex := strings.ReplaceAll(strings.TrimSpace(hexDump), "\n", " ")
|
|
||||||
log.Printf("源码: %s", sourceHex)
|
|
||||||
|
|
||||||
// 解析RS485数据
|
|
||||||
protocol := model.NewProtocol(rawData)
|
|
||||||
rs485Protocol := model.NewRS485Protocol(rawData)
|
|
||||||
|
|
||||||
// 获取设备ID
|
|
||||||
idParts, err := protocol.GetCompleteID()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("获取设备ID失败: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析RS485数据
|
|
||||||
rs485Data, err := rs485Protocol.ParseRS485Data()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("解析RS485数据失败: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加设备ID和时间戳
|
|
||||||
rs485Data.DeviceID = idParts.Complete.Hex
|
|
||||||
rs485Data.ReceivedAt = time.Now()
|
|
||||||
rs485Data.RawDataHex = sourceHex
|
|
||||||
|
|
||||||
// 打印解析结果到日志
|
|
||||||
log.Println("=== RS485 ===")
|
|
||||||
log.Printf("设备ID: RS485-%s", rs485Data.DeviceID)
|
|
||||||
log.Printf("温度: %.2f°C", rs485Data.Temperature)
|
|
||||||
log.Printf("湿度: %.1f%%", rs485Data.Humidity)
|
|
||||||
log.Printf("风速: %.5f m/s", rs485Data.WindSpeed)
|
|
||||||
log.Printf("风向: %.1f°", rs485Data.WindDirection)
|
|
||||||
log.Printf("降雨量: %.3f mm", rs485Data.Rainfall)
|
|
||||||
log.Printf("光照: %.1f lux", rs485Data.Light)
|
|
||||||
log.Printf("紫外线: %.1f", rs485Data.UV)
|
|
||||||
log.Printf("气压: %.2f hPa", rs485Data.Pressure)
|
|
||||||
log.Printf("接收时间: %s", rs485Data.ReceivedAt.Format("2006-01-02 15:04:05"))
|
|
||||||
|
|
||||||
// 注册设备
|
|
||||||
stationID := fmt.Sprintf("RS485-%s", rs485Data.DeviceID)
|
|
||||||
model.RegisterDevice(stationID, addr)
|
|
||||||
log.Printf("设备 %s 已注册,IP: %s", stationID, addr.String())
|
|
||||||
|
|
||||||
// 保存到数据库
|
|
||||||
err = model.SaveWeatherData(rs485Data, string(rawData))
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("保存数据到数据库失败: %v", err)
|
|
||||||
} else {
|
|
||||||
log.Printf("数据已成功保存到数据库")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleWiFiData 处理WiFi设备数据
|
|
||||||
func handleWiFiData(rawData []byte, addr net.Addr) {
|
|
||||||
// 尝试解析WIFI数据
|
|
||||||
data, deviceType, err := model.ParseData(rawData)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("解析数据失败: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("成功解析气象站数据:")
|
|
||||||
log.Printf("设备类型: %s", getDeviceTypeString(deviceType))
|
|
||||||
log.Println(data)
|
|
||||||
|
|
||||||
if deviceType == model.DeviceTypeWIFI {
|
|
||||||
if wifiData, ok := data.(*model.WeatherData); ok {
|
|
||||||
stationID := wifiData.StationID
|
|
||||||
if stationID != "" {
|
|
||||||
model.RegisterDevice(stationID, addr)
|
|
||||||
log.Printf("设备 %s 已注册,IP: %s", stationID, addr.String())
|
|
||||||
} else {
|
|
||||||
log.Printf("警告: 收到的数据没有站点ID")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getDeviceTypeString 获取设备类型字符串
|
|
||||||
func getDeviceTypeString(deviceType model.DeviceType) string {
|
|
||||||
switch deviceType {
|
|
||||||
case model.DeviceTypeWIFI:
|
|
||||||
return "WIFI"
|
|
||||||
case model.DeviceTypeRS485:
|
|
||||||
return "RS485"
|
|
||||||
default:
|
|
||||||
return "未知"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// hexDump 生成十六进制转储
|
|
||||||
func hexDump(data []byte) string {
|
|
||||||
var result strings.Builder
|
|
||||||
for i := 0; i < len(data); i += 16 {
|
|
||||||
end := i + 16
|
|
||||||
if end > len(data) {
|
|
||||||
end = len(data)
|
|
||||||
}
|
|
||||||
chunk := data[i:end]
|
|
||||||
hexStr := hex.EncodeToString(chunk)
|
|
||||||
for j := 0; j < len(hexStr); j += 2 {
|
|
||||||
if j+2 <= len(hexStr) {
|
|
||||||
result.WriteString(strings.ToUpper(hexStr[j : j+2]))
|
|
||||||
result.WriteString(" ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.WriteString("\n")
|
|
||||||
}
|
|
||||||
return result.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// asciiDump 生成ASCII转储
|
|
||||||
func asciiDump(data []byte) string {
|
|
||||||
var result strings.Builder
|
|
||||||
for i := 0; i < len(data); i += 64 {
|
|
||||||
end := i + 64
|
|
||||||
if end > len(data) {
|
|
||||||
end = len(data)
|
|
||||||
}
|
|
||||||
chunk := data[i:end]
|
|
||||||
for _, b := range chunk {
|
|
||||||
if b >= 32 && b <= 126 {
|
|
||||||
result.WriteByte(b)
|
|
||||||
} else {
|
|
||||||
result.WriteString(".")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.WriteString("\n")
|
|
||||||
}
|
|
||||||
return result.String()
|
|
||||||
}
|
|
||||||
@ -1,257 +0,0 @@
|
|||||||
package tools
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"weatherstation/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
type BackfillOptions struct {
|
|
||||||
StationID string // 为空则处理所有站点
|
|
||||||
FromTime time.Time // 含
|
|
||||||
ToTime time.Time // 含
|
|
||||||
WrapCycleMM float64 // 设备累计回绕一圈对应的毫米值;<=0 则按“回绕后仅记当次值”降级处理
|
|
||||||
BucketMinutes int // 默认10
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunBackfill10Min 将 rs485_weather_data 的16秒数据汇总写入 rs485_weather_10min
|
|
||||||
func RunBackfill10Min(ctx context.Context, opts BackfillOptions) error {
|
|
||||||
if opts.BucketMinutes <= 0 {
|
|
||||||
opts.BucketMinutes = 10
|
|
||||||
}
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
// 取时序数据
|
|
||||||
query := `
|
|
||||||
SELECT station_id, timestamp, temperature, humidity, wind_speed, wind_direction,
|
|
||||||
rainfall, light, uv, pressure
|
|
||||||
FROM rs485_weather_data
|
|
||||||
WHERE timestamp >= $1 AND timestamp <= $2
|
|
||||||
%s
|
|
||||||
ORDER BY station_id, timestamp`
|
|
||||||
|
|
||||||
stationFilter := ""
|
|
||||||
args := []any{opts.FromTime, opts.ToTime}
|
|
||||||
if opts.StationID != "" {
|
|
||||||
stationFilter = "AND station_id = $3"
|
|
||||||
args = append(args, opts.StationID)
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := db.QueryContext(ctx, fmt.Sprintf(query, stationFilter), args...)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("query raw failed: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
// 载入上海时区用于分桶
|
|
||||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
|
||||||
if loc == nil {
|
|
||||||
loc = time.FixedZone("CST", 8*3600)
|
|
||||||
}
|
|
||||||
|
|
||||||
type agg struct {
|
|
||||||
// sums
|
|
||||||
sumTemp float64
|
|
||||||
sumHum float64
|
|
||||||
sumWS float64
|
|
||||||
sumLight float64
|
|
||||||
sumUV float64
|
|
||||||
sumP float64
|
|
||||||
sinSum float64
|
|
||||||
cosSum float64
|
|
||||||
gustMax float64
|
|
||||||
rainIncSum float64
|
|
||||||
count int
|
|
||||||
lastTotal float64 // 桶末累计
|
|
||||||
lastTS time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
currentStation := ""
|
|
||||||
var prevTotal float64
|
|
||||||
var prevTS time.Time
|
|
||||||
|
|
||||||
buckets := make(map[string]map[time.Time]*agg) // station -> bucketStart -> agg
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var (
|
|
||||||
stationID string
|
|
||||||
ts time.Time
|
|
||||||
t, h, ws, wd, rf, light, uv, p sql.NullFloat64
|
|
||||||
)
|
|
||||||
if err := rows.Scan(&stationID, &ts, &t, &h, &ws, &wd, &rf, &light, &uv, &p); err != nil {
|
|
||||||
return fmt.Errorf("scan failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换设备时重置 prevTotal
|
|
||||||
if stationID != currentStation {
|
|
||||||
currentStation = stationID
|
|
||||||
prevTotal = math.NaN()
|
|
||||||
prevTS = time.Time{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算该样本所在桶(CST对齐)
|
|
||||||
bucketLocal := ts.In(loc).Truncate(time.Duration(opts.BucketMinutes) * time.Minute)
|
|
||||||
bucketStart := time.Date(bucketLocal.Year(), bucketLocal.Month(), bucketLocal.Day(), bucketLocal.Hour(), bucketLocal.Minute(), 0, 0, loc)
|
|
||||||
|
|
||||||
if _, ok := buckets[stationID]; !ok {
|
|
||||||
buckets[stationID] = make(map[time.Time]*agg)
|
|
||||||
}
|
|
||||||
ag := buckets[stationID][bucketStart]
|
|
||||||
if ag == nil {
|
|
||||||
ag = &agg{gustMax: -1}
|
|
||||||
buckets[stationID][bucketStart] = ag
|
|
||||||
}
|
|
||||||
|
|
||||||
// 累加平均项
|
|
||||||
if t.Valid {
|
|
||||||
ag.sumTemp += t.Float64
|
|
||||||
}
|
|
||||||
if h.Valid {
|
|
||||||
ag.sumHum += h.Float64
|
|
||||||
}
|
|
||||||
if ws.Valid {
|
|
||||||
ag.sumWS += ws.Float64
|
|
||||||
if ws.Float64 > ag.gustMax {
|
|
||||||
ag.gustMax = ws.Float64
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if light.Valid {
|
|
||||||
ag.sumLight += light.Float64
|
|
||||||
}
|
|
||||||
if uv.Valid {
|
|
||||||
ag.sumUV += uv.Float64
|
|
||||||
}
|
|
||||||
if p.Valid {
|
|
||||||
ag.sumP += p.Float64
|
|
||||||
}
|
|
||||||
if wd.Valid {
|
|
||||||
rad := wd.Float64 * math.Pi / 180.0
|
|
||||||
ag.sinSum += math.Sin(rad)
|
|
||||||
ag.cosSum += math.Cos(rad)
|
|
||||||
}
|
|
||||||
ag.count++
|
|
||||||
|
|
||||||
// 雨量增量:按时间比例切分到跨越的各个桶,避免边界全部被计入后一桶
|
|
||||||
if rf.Valid {
|
|
||||||
curr := rf.Float64
|
|
||||||
if !math.IsNaN(prevTotal) && !prevTS.IsZero() {
|
|
||||||
// 计算增量(带回绕)
|
|
||||||
inc := 0.0
|
|
||||||
if curr >= prevTotal {
|
|
||||||
inc = curr - prevTotal
|
|
||||||
} else {
|
|
||||||
if opts.WrapCycleMM > 0 {
|
|
||||||
inc = (opts.WrapCycleMM - prevTotal) + curr
|
|
||||||
} else {
|
|
||||||
// 降级:仅计当前值
|
|
||||||
inc = curr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将 [prevTS, ts] 区间按10分钟边界切分,按时长比例分配增量
|
|
||||||
startLocal := prevTS.In(loc)
|
|
||||||
endLocal := ts.In(loc)
|
|
||||||
if endLocal.After(startLocal) && inc > 0 {
|
|
||||||
totalSec := endLocal.Sub(startLocal).Seconds()
|
|
||||||
segStart := startLocal
|
|
||||||
for segStart.Before(endLocal) {
|
|
||||||
segBucketStart := segStart.Truncate(time.Duration(opts.BucketMinutes) * time.Minute)
|
|
||||||
segBucketEnd := segBucketStart.Add(time.Duration(opts.BucketMinutes) * time.Minute)
|
|
||||||
segEnd := endLocal
|
|
||||||
if segBucketEnd.Before(segEnd) {
|
|
||||||
segEnd = segBucketEnd
|
|
||||||
}
|
|
||||||
portion := inc * (segEnd.Sub(segStart).Seconds() / totalSec)
|
|
||||||
// 确保段对应桶存在
|
|
||||||
if _, ok := buckets[stationID][segBucketStart]; !ok {
|
|
||||||
buckets[stationID][segBucketStart] = &agg{gustMax: -1}
|
|
||||||
}
|
|
||||||
buckets[stationID][segBucketStart].rainIncSum += portion
|
|
||||||
segStart = segEnd
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 记录桶末累计到当前样本所在桶
|
|
||||||
ag.lastTotal = curr
|
|
||||||
prevTotal = curr
|
|
||||||
prevTS = ts
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return fmt.Errorf("iterate rows failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写入/更新到 10 分钟表
|
|
||||||
upsert := `
|
|
||||||
INSERT INTO rs485_weather_10min (
|
|
||||||
station_id, bucket_start,
|
|
||||||
temp_c_x100, humidity_pct, wind_speed_ms_x1000, wind_gust_ms_x1000,
|
|
||||||
wind_dir_deg, rain_10m_mm_x1000, rain_total_mm_x1000,
|
|
||||||
solar_wm2_x100, uv_index, pressure_hpa_x100, sample_count
|
|
||||||
) VALUES (
|
|
||||||
$1, $2, $3, $4, $5, $6,
|
|
||||||
$7, $8, $9,
|
|
||||||
$10, $11, $12, $13
|
|
||||||
) ON CONFLICT (station_id, bucket_start) DO UPDATE SET
|
|
||||||
temp_c_x100 = EXCLUDED.temp_c_x100,
|
|
||||||
humidity_pct = EXCLUDED.humidity_pct,
|
|
||||||
wind_speed_ms_x1000 = EXCLUDED.wind_speed_ms_x1000,
|
|
||||||
wind_gust_ms_x1000 = EXCLUDED.wind_gust_ms_x1000,
|
|
||||||
wind_dir_deg = EXCLUDED.wind_dir_deg,
|
|
||||||
rain_10m_mm_x1000 = EXCLUDED.rain_10m_mm_x1000,
|
|
||||||
rain_total_mm_x1000 = EXCLUDED.rain_total_mm_x1000,
|
|
||||||
solar_wm2_x100 = EXCLUDED.solar_wm2_x100,
|
|
||||||
uv_index = EXCLUDED.uv_index,
|
|
||||||
pressure_hpa_x100 = EXCLUDED.pressure_hpa_x100,
|
|
||||||
sample_count = EXCLUDED.sample_count`
|
|
||||||
|
|
||||||
for stationID, m := range buckets {
|
|
||||||
for bucketStart, ag := range m {
|
|
||||||
if ag.count == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
avgTemp := ag.sumTemp / float64(ag.count)
|
|
||||||
avgHum := ag.sumHum / float64(ag.count)
|
|
||||||
avgWS := ag.sumWS / float64(ag.count)
|
|
||||||
avgLight := ag.sumLight / float64(ag.count)
|
|
||||||
avgUV := ag.sumUV / float64(ag.count)
|
|
||||||
avgP := ag.sumP / float64(ag.count)
|
|
||||||
// 风向向量平均
|
|
||||||
windDir := 0.0
|
|
||||||
if ag.sinSum != 0 || ag.cosSum != 0 {
|
|
||||||
ang := math.Atan2(ag.sinSum/float64(ag.count), ag.cosSum/float64(ag.count)) * 180 / math.Pi
|
|
||||||
if ang < 0 {
|
|
||||||
ang += 360
|
|
||||||
}
|
|
||||||
windDir = ang
|
|
||||||
}
|
|
||||||
|
|
||||||
// 缩放整数
|
|
||||||
tempScaled := int(math.Round(avgTemp * 100))
|
|
||||||
humScaled := int(math.Round(avgHum))
|
|
||||||
wsScaled := int(math.Round(avgWS * 1000))
|
|
||||||
gustScaled := int(math.Round(ag.gustMax * 1000))
|
|
||||||
wdScaled := int(math.Round(windDir))
|
|
||||||
rain10mScaled := int(math.Round(ag.rainIncSum * 1000))
|
|
||||||
rainTotalScaled := int(math.Round(ag.lastTotal * 1000))
|
|
||||||
solarScaled := int(math.Round(avgLight * 100))
|
|
||||||
uvScaled := int(math.Round(avgUV))
|
|
||||||
pScaled := int(math.Round(avgP * 100))
|
|
||||||
|
|
||||||
if _, err := db.ExecContext(ctx, upsert,
|
|
||||||
stationID, bucketStart,
|
|
||||||
tempScaled, humScaled, wsScaled, gustScaled,
|
|
||||||
wdScaled, rain10mScaled, rainTotalScaled,
|
|
||||||
solarScaled, uvScaled, pScaled, ag.count,
|
|
||||||
); err != nil {
|
|
||||||
return fmt.Errorf("upsert 10min failed: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
54
launcher.go
Normal file
54
launcher.go
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var webOnly = flag.Bool("web", false, "只启动Web服务器(原生http)")
|
||||||
|
var ginOnly = flag.Bool("gin", false, "只启动Gin Web服务器")
|
||||||
|
var udpOnly = flag.Bool("udp", false, "只启动UDP服务器")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// 设置日志
|
||||||
|
setupLogger()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
if *webOnly {
|
||||||
|
// 只启动原生Web服务器
|
||||||
|
log.Println("启动原生Web服务器模式...")
|
||||||
|
StartWebServer()
|
||||||
|
} else if *ginOnly {
|
||||||
|
// 只启动Gin Web服务器
|
||||||
|
log.Println("启动Gin Web服务器模式...")
|
||||||
|
StartGinServer()
|
||||||
|
} else if *udpOnly {
|
||||||
|
// 只启动UDP服务器
|
||||||
|
log.Println("启动UDP服务器模式...")
|
||||||
|
startUDP()
|
||||||
|
} else {
|
||||||
|
// 同时启动UDP和Gin Web服务器
|
||||||
|
log.Println("启动完整模式:UDP + Gin Web服务器...")
|
||||||
|
|
||||||
|
wg.Add(2)
|
||||||
|
|
||||||
|
// 启动UDP服务器
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
log.Println("正在启动UDP服务器...")
|
||||||
|
startUDP()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 启动Gin Web服务器
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
log.Println("正在启动Gin Web服务器...")
|
||||||
|
StartGinServer()
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package types
|
package main
|
||||||
|
|
||||||
// Station 站点信息
|
// Station 站点信息
|
||||||
type Station struct {
|
type Station struct {
|
||||||
263
udp_server.go
Normal file
263
udp_server.go
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"weatherstation/config"
|
||||||
|
"weatherstation/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UTF8Writer struct {
|
||||||
|
w io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUTF8Writer(w io.Writer) *UTF8Writer {
|
||||||
|
return &UTF8Writer{w: w}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *UTF8Writer) Write(p []byte) (n int, err error) {
|
||||||
|
if utf8.Valid(p) {
|
||||||
|
return w.w.Write(p)
|
||||||
|
}
|
||||||
|
s := string(p)
|
||||||
|
s = strings.ToValidUTF8(s, "")
|
||||||
|
return w.w.Write([]byte(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
logFile *os.File
|
||||||
|
logFileMutex sync.Mutex
|
||||||
|
currentLogDay int
|
||||||
|
)
|
||||||
|
|
||||||
|
func getLogFileName() string {
|
||||||
|
currentTime := time.Now()
|
||||||
|
return filepath.Join("log", fmt.Sprintf("%s.log", currentTime.Format("2006-01-02")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func openLogFile() (*os.File, error) {
|
||||||
|
logDir := "log"
|
||||||
|
if _, err := os.Stat(logDir); os.IsNotExist(err) {
|
||||||
|
os.MkdirAll(logDir, 0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
logFileName := getLogFileName()
|
||||||
|
return os.OpenFile(logFileName, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupLogger() {
|
||||||
|
var err error
|
||||||
|
logFile, err = openLogFile()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("无法创建日志文件: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentLogDay = time.Now().Day()
|
||||||
|
|
||||||
|
bufferedWriter := bufio.NewWriter(logFile)
|
||||||
|
utf8Writer := NewUTF8Writer(bufferedWriter)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
|
logFileMutex.Lock()
|
||||||
|
bufferedWriter.Flush()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if now.Day() != currentLogDay {
|
||||||
|
oldLogFile := logFile
|
||||||
|
logFile, err = openLogFile()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("无法创建新日志文件: %v", err)
|
||||||
|
} else {
|
||||||
|
oldLogFile.Close()
|
||||||
|
currentLogDay = now.Day()
|
||||||
|
bufferedWriter = bufio.NewWriter(logFile)
|
||||||
|
utf8Writer = NewUTF8Writer(bufferedWriter)
|
||||||
|
log.SetOutput(io.MultiWriter(os.Stdout, utf8Writer))
|
||||||
|
log.Println("日志文件已轮转")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logFileMutex.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
multiWriter := io.MultiWriter(os.Stdout, utf8Writer)
|
||||||
|
log.SetOutput(multiWriter)
|
||||||
|
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startUDP() {
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
err := model.InitDB()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("初始化数据库失败: %v", err)
|
||||||
|
}
|
||||||
|
defer model.CloseDB()
|
||||||
|
addr := fmt.Sprintf(":%d", cfg.Server.UDPPort)
|
||||||
|
conn, err := net.ListenPacket("udp", addr)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("无法监听UDP端口 %d: %v", cfg.Server.UDPPort, err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
log.Printf("UDP服务器已启动,监听端口 %d...", cfg.Server.UDPPort)
|
||||||
|
buffer := make([]byte, 2048)
|
||||||
|
for {
|
||||||
|
n, addr, err := conn.ReadFrom(buffer)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("读取数据错误: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rawData := buffer[:n]
|
||||||
|
log.Printf("从 %s 接收到 %d 字节数据", addr.String(), n)
|
||||||
|
|
||||||
|
hexDump := hexDump(rawData)
|
||||||
|
log.Printf("原始码流(十六进制):\n%s", hexDump)
|
||||||
|
asciiDump := asciiDump(rawData)
|
||||||
|
log.Printf("ASCII码:\n%s", asciiDump)
|
||||||
|
|
||||||
|
if len(rawData) == 25 && rawData[0] == 0x24 {
|
||||||
|
log.Println("485 型气象站数据")
|
||||||
|
|
||||||
|
// 生成源码字符串(用于日志记录)
|
||||||
|
sourceHex := strings.ReplaceAll(strings.TrimSpace(hexDump), "\n", " ")
|
||||||
|
log.Printf("源码: %s", sourceHex)
|
||||||
|
|
||||||
|
// 解析RS485数据
|
||||||
|
protocol := model.NewProtocol(rawData)
|
||||||
|
rs485Protocol := model.NewRS485Protocol(rawData)
|
||||||
|
|
||||||
|
// 获取设备ID
|
||||||
|
idParts, err := protocol.GetCompleteID()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("获取设备ID失败: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析RS485数据
|
||||||
|
rs485Data, err := rs485Protocol.ParseRS485Data()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("解析RS485数据失败: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加设备ID和时间戳
|
||||||
|
rs485Data.DeviceID = idParts.Complete.Hex
|
||||||
|
rs485Data.ReceivedAt = time.Now()
|
||||||
|
rs485Data.RawDataHex = sourceHex
|
||||||
|
|
||||||
|
// 打印解析结果到日志
|
||||||
|
log.Println("=== RS485 ===")
|
||||||
|
log.Printf("设备ID: RS485-%s", rs485Data.DeviceID)
|
||||||
|
log.Printf("温度: %.2f°C", rs485Data.Temperature)
|
||||||
|
log.Printf("湿度: %.1f%%", rs485Data.Humidity)
|
||||||
|
log.Printf("风速: %.5f m/s", rs485Data.WindSpeed)
|
||||||
|
log.Printf("风向: %.1f°", rs485Data.WindDirection)
|
||||||
|
log.Printf("降雨量: %.3f mm", rs485Data.Rainfall)
|
||||||
|
log.Printf("光照: %.1f lux", rs485Data.Light)
|
||||||
|
log.Printf("紫外线: %.1f", rs485Data.UV)
|
||||||
|
log.Printf("气压: %.2f hPa", rs485Data.Pressure)
|
||||||
|
log.Printf("接收时间: %s", rs485Data.ReceivedAt.Format("2006-01-02 15:04:05"))
|
||||||
|
|
||||||
|
// 注册设备
|
||||||
|
stationID := fmt.Sprintf("RS485-%s", rs485Data.DeviceID)
|
||||||
|
model.RegisterDevice(stationID, addr)
|
||||||
|
log.Printf("设备 %s 已注册,IP: %s", stationID, addr.String())
|
||||||
|
|
||||||
|
// 保存到数据库
|
||||||
|
err = model.SaveWeatherData(rs485Data, string(rawData))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("保存数据到数据库失败: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("数据已成功保存到数据库")
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// 尝试解析WIFI数据
|
||||||
|
data, deviceType, err := model.ParseData(rawData)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("解析数据失败: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("成功解析气象站数据:")
|
||||||
|
log.Printf("设备类型: %s", getDeviceTypeString(deviceType))
|
||||||
|
log.Println(data)
|
||||||
|
|
||||||
|
if deviceType == model.DeviceTypeWIFI {
|
||||||
|
if wifiData, ok := data.(*model.WeatherData); ok {
|
||||||
|
stationID := wifiData.StationID
|
||||||
|
if stationID != "" {
|
||||||
|
model.RegisterDevice(stationID, addr)
|
||||||
|
log.Printf("设备 %s 已注册,IP: %s", stationID, addr.String())
|
||||||
|
} else {
|
||||||
|
log.Printf("警告: 收到的数据没有站点ID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDeviceTypeString(deviceType model.DeviceType) string {
|
||||||
|
switch deviceType {
|
||||||
|
case model.DeviceTypeWIFI:
|
||||||
|
return "WIFI"
|
||||||
|
case model.DeviceTypeRS485:
|
||||||
|
return "RS485"
|
||||||
|
default:
|
||||||
|
return "未知"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hexDump(data []byte) string {
|
||||||
|
var result strings.Builder
|
||||||
|
for i := 0; i < len(data); i += 16 {
|
||||||
|
end := i + 16
|
||||||
|
if end > len(data) {
|
||||||
|
end = len(data)
|
||||||
|
}
|
||||||
|
chunk := data[i:end]
|
||||||
|
hexStr := hex.EncodeToString(chunk)
|
||||||
|
for j := 0; j < len(hexStr); j += 2 {
|
||||||
|
if j+2 <= len(hexStr) {
|
||||||
|
result.WriteString(strings.ToUpper(hexStr[j : j+2]))
|
||||||
|
result.WriteString(" ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.WriteString("\n")
|
||||||
|
}
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func asciiDump(data []byte) string {
|
||||||
|
var result strings.Builder
|
||||||
|
for i := 0; i < len(data); i += 64 {
|
||||||
|
end := i + 64
|
||||||
|
if end > len(data) {
|
||||||
|
end = len(data)
|
||||||
|
}
|
||||||
|
chunk := data[i:end]
|
||||||
|
for _, b := range chunk {
|
||||||
|
if b >= 32 && b <= 126 {
|
||||||
|
result.WriteByte(b)
|
||||||
|
} else {
|
||||||
|
result.WriteString(".")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.WriteString("\n")
|
||||||
|
}
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
240
web_server.go
Normal file
240
web_server.go
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"weatherstation/config"
|
||||||
|
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
)
|
||||||
|
|
||||||
|
var db *sql.DB
|
||||||
|
|
||||||
|
func initWebDB() error {
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
|
||||||
|
connStr := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
|
||||||
|
cfg.Database.Host, cfg.Database.Port, cfg.Database.User,
|
||||||
|
cfg.Database.Password, cfg.Database.DBName, cfg.Database.SSLMode)
|
||||||
|
|
||||||
|
var err error
|
||||||
|
db, err = sql.Open("postgres", connStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("无法连接到数据库: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.Ping()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("数据库连接测试失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取WH65LP站点列表
|
||||||
|
func getWH65LPStations(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT DISTINCT s.station_id,
|
||||||
|
COALESCE(s.password, '') as station_name,
|
||||||
|
'WH65LP' as device_type,
|
||||||
|
COALESCE(MAX(r.timestamp), '1970-01-01'::timestamp) as last_update
|
||||||
|
FROM stations s
|
||||||
|
LEFT JOIN rs485_weather_data r ON s.station_id = r.station_id
|
||||||
|
WHERE s.station_id LIKE 'RS485-%'
|
||||||
|
GROUP BY s.station_id, s.password
|
||||||
|
ORDER BY s.station_id`
|
||||||
|
|
||||||
|
rows, err := db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "查询站点失败", http.StatusInternalServerError)
|
||||||
|
log.Printf("查询站点失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var stations []Station
|
||||||
|
for rows.Next() {
|
||||||
|
var station Station
|
||||||
|
var lastUpdate time.Time
|
||||||
|
err := rows.Scan(&station.StationID, &station.StationName, &station.DeviceType, &lastUpdate)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("扫描站点数据失败: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
station.LastUpdate = lastUpdate.Format("2006-01-02 15:04:05")
|
||||||
|
stations = append(stations, station)
|
||||||
|
}
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(stations)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取站点历史数据
|
||||||
|
func getStationData(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
|
stationID := r.URL.Query().Get("station_id")
|
||||||
|
startTime := r.URL.Query().Get("start_time")
|
||||||
|
endTime := r.URL.Query().Get("end_time")
|
||||||
|
interval := r.URL.Query().Get("interval")
|
||||||
|
|
||||||
|
if stationID == "" || startTime == "" || endTime == "" {
|
||||||
|
http.Error(w, "缺少必要参数", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认间隔为1小时
|
||||||
|
if interval == "" {
|
||||||
|
interval = "1hour"
|
||||||
|
}
|
||||||
|
|
||||||
|
var query string
|
||||||
|
var intervalSQL string
|
||||||
|
|
||||||
|
switch interval {
|
||||||
|
case "10min":
|
||||||
|
intervalSQL = "10 minutes"
|
||||||
|
case "30min":
|
||||||
|
intervalSQL = "30 minutes"
|
||||||
|
case "1hour":
|
||||||
|
intervalSQL = "1 hour"
|
||||||
|
default:
|
||||||
|
intervalSQL = "1 hour"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建查询SQL - 使用时间窗口聚合
|
||||||
|
query = fmt.Sprintf(`
|
||||||
|
WITH time_series AS (
|
||||||
|
SELECT
|
||||||
|
date_trunc('hour', timestamp) +
|
||||||
|
INTERVAL '%s' * FLOOR(EXTRACT(EPOCH FROM timestamp - date_trunc('hour', timestamp)) / EXTRACT(EPOCH FROM INTERVAL '%s')) as time_bucket,
|
||||||
|
temperature,
|
||||||
|
humidity,
|
||||||
|
pressure,
|
||||||
|
wind_speed,
|
||||||
|
wind_direction,
|
||||||
|
rainfall,
|
||||||
|
light,
|
||||||
|
uv
|
||||||
|
FROM rs485_weather_data
|
||||||
|
WHERE station_id = $1
|
||||||
|
AND timestamp >= $2::timestamp
|
||||||
|
AND timestamp <= $3::timestamp
|
||||||
|
),
|
||||||
|
aggregated_data AS (
|
||||||
|
SELECT
|
||||||
|
time_bucket,
|
||||||
|
ROUND(AVG(temperature)::numeric, 2) as temperature,
|
||||||
|
ROUND(AVG(humidity)::numeric, 2) as humidity,
|
||||||
|
ROUND(AVG(pressure)::numeric, 2) as pressure,
|
||||||
|
ROUND(AVG(wind_speed)::numeric, 2) as wind_speed,
|
||||||
|
-- 风向使用矢量平均
|
||||||
|
ROUND(DEGREES(ATAN2(
|
||||||
|
AVG(SIN(RADIANS(wind_direction))),
|
||||||
|
AVG(COS(RADIANS(wind_direction)))
|
||||||
|
))::numeric + CASE
|
||||||
|
WHEN DEGREES(ATAN2(
|
||||||
|
AVG(SIN(RADIANS(wind_direction))),
|
||||||
|
AVG(COS(RADIANS(wind_direction)))
|
||||||
|
)) < 0 THEN 360
|
||||||
|
ELSE 0
|
||||||
|
END, 2) as wind_direction,
|
||||||
|
-- 雨量使用差值计算
|
||||||
|
ROUND((MAX(rainfall) - MIN(rainfall))::numeric, 3) as rainfall_diff,
|
||||||
|
ROUND(AVG(light)::numeric, 2) as light,
|
||||||
|
ROUND(AVG(uv)::numeric, 2) as uv
|
||||||
|
FROM time_series
|
||||||
|
GROUP BY time_bucket
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
time_bucket,
|
||||||
|
COALESCE(temperature, 0) as temperature,
|
||||||
|
COALESCE(humidity, 0) as humidity,
|
||||||
|
COALESCE(pressure, 0) as pressure,
|
||||||
|
COALESCE(wind_speed, 0) as wind_speed,
|
||||||
|
COALESCE(wind_direction, 0) as wind_direction,
|
||||||
|
COALESCE(rainfall_diff, 0) as rainfall,
|
||||||
|
COALESCE(light, 0) as light,
|
||||||
|
COALESCE(uv, 0) as uv
|
||||||
|
FROM aggregated_data
|
||||||
|
ORDER BY time_bucket`, intervalSQL, intervalSQL)
|
||||||
|
|
||||||
|
rows, err := db.Query(query, stationID, startTime, endTime)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "查询数据失败", http.StatusInternalServerError)
|
||||||
|
log.Printf("查询数据失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var data []WeatherPoint
|
||||||
|
for rows.Next() {
|
||||||
|
var point WeatherPoint
|
||||||
|
var timestamp time.Time
|
||||||
|
err := rows.Scan(×tamp, &point.Temperature, &point.Humidity,
|
||||||
|
&point.Pressure, &point.WindSpeed, &point.WindDir,
|
||||||
|
&point.Rainfall, &point.Light, &point.UV)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("扫描数据失败: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
point.DateTime = timestamp.Format("2006-01-02 15:04:05")
|
||||||
|
data = append(data, point)
|
||||||
|
}
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提供静态文件服务
|
||||||
|
func serveStaticFiles() {
|
||||||
|
// 获取当前工作目录
|
||||||
|
workDir := "/home/yarnom/Archive/code/WeatherStation"
|
||||||
|
webDir := filepath.Join(workDir, "web")
|
||||||
|
|
||||||
|
// 创建文件服务器
|
||||||
|
fs := http.FileServer(http.Dir(webDir))
|
||||||
|
|
||||||
|
// 处理根路径请求
|
||||||
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/" {
|
||||||
|
http.ServeFile(w, r, filepath.Join(webDir, "index.html"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件是否存在
|
||||||
|
if _, err := http.Dir(webDir).Open(strings.TrimPrefix(r.URL.Path, "/")); err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提供静态文件
|
||||||
|
fs.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartWebServer() {
|
||||||
|
err := initWebDB()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("初始化Web数据库连接失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// API路由
|
||||||
|
http.HandleFunc("/api/stations", getWH65LPStations)
|
||||||
|
http.HandleFunc("/api/data", getStationData)
|
||||||
|
|
||||||
|
// 静态文件服务
|
||||||
|
serveStaticFiles()
|
||||||
|
|
||||||
|
log.Println("Web服务器启动,监听端口 10003...")
|
||||||
|
log.Fatal(http.ListenAndServe(":10003", nil))
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user